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
···
+
# generate with `openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32`
+
COOKIE_SECRET=my_secret
+
MIGRATION_STATE=up
+2
.gitignore
···
.env.production.local
.env.local
+
.DS_Store
+
# Fresh build directory
_fresh/
# npm dependencies
+35 -9
.zed/settings.json
···
+
// Folder-specific settings
+
//
+
// For a full list of overridable settings, and general information on folder-specific settings,
+
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
+
"lsp": {
+
"deno": {
+
"settings": {
+
"deno": {
+
"enable": true,
+
"cacheOnSave": true,
+
"suggest": {
+
"imports": {
+
"autoDiscover": true
+
}
+
}
+
}
+
}
+
}
+
},
"languages": {
+
"JavaScript": {
+
"language_servers": [
+
"deno",
+
"!vtsls",
+
"!eslint",
+
"..."
+
]
+
},
"TypeScript": {
"language_servers": [
-
"wakatime",
"deno",
"!typescript-language-server",
"!vtsls",
-
"!eslint"
-
],
-
"formatter": "language_server"
+
"!eslint",
+
"..."
+
]
},
"TSX": {
"language_servers": [
-
"wakatime",
"deno",
"!typescript-language-server",
"!vtsls",
-
"!eslint"
-
],
-
"formatter": "language_server"
+
"!eslint",
+
"..."
+
]
}
-
}
+
},
+
"formatter": "language_server"
}
+19
LICENSE
···
+
Copyright 2025 Roscoe Rubin-Rottenberg
+
+
Permission is hereby granted, free of charge, to any person obtaining
+
a copy of this software and associated documentation files (the โ€œSoftwareโ€),
+
to deal in the Software without restriction, including without limitation
+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
+
and/or sell copies of the Software, and to permit persons to whom the
+
Software is furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+
all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+
IN THE SOFTWARE.
+32 -8
README.md
···
-
# Fresh project
+
# Airport
-
Your new Fresh project is ready to go. You can follow the Fresh "Getting
-
Started" guide here: https://fresh.deno.dev/docs/getting-started
+
Your terminal for seamless AT Protocol PDS (Personal Data Server) migration and backup.
-
### Usage
+
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.
+
+
## Features
-
Make sure to install Deno:
+
- PDS migration between servers
+
- Data backup functionality
+
- User-friendly interface
+
- Coming soon: PLC Key retrieval, data backup
+
+
## Tech Stack
+
+
- [Fresh](https://fresh.deno.dev/) - Web Framework
+
- [Deno](https://deno.com/) - Runtime
+
- [Tailwind](https://tailwindcss.com/) - Styling
+
+
## Development
+
+
Make sure you have Deno installed:
https://docs.deno.com/runtime/getting_started/installation
-
Then start the project in development mode:
+
Start the project in development mode:
-
```
+
```shell
deno task dev
```
-
This will watch the project directory and restart as necessary.
+
## About
+
+
Airport is developed with โค๏ธ by [Roscoe](https://bsky.app/profile/knotbin.com) for [Spark](https://sprk.so), a new short-video platform for AT Protocol.
+
+
## Contributing
+
+
We welcome contributions! Please feel free to submit a Pull Request. Please only submit pull requests that are relevant to the project. This project targets people with a non-advanced understanding of AT Protocol, so please avoid submitting pull requests that add features that complicate the user experience.
+
+
## License
+
+
[MIT License](LICENSE)
+73
components/AirportSign.tsx
···
+
/**
+
* The airport sign component, used on the landing page.
+
* Looks like a physical airport sign with a screen.
+
* @returns The airport sign component
+
* @component
+
*/
+
export default function AirportSign() {
+
return (
+
<div class="relative inline-block mb-8 sm:mb-12">
+
{/* Left Pole */}
+
<div class="absolute left-4 sm:left-8 -top-16 sm:-top-24 w-3 sm:w-4 h-16 sm:h-24 bg-gradient-to-r from-slate-800 via-slate-600 to-slate-800 rounded-t-lg">
+
</div>
+
{/* Right Pole */}
+
<div class="absolute right-4 sm:right-8 -top-16 sm:-top-24 w-3 sm:w-4 h-16 sm:h-24 bg-gradient-to-r from-slate-800 via-slate-600 to-slate-800 rounded-t-lg">
+
</div>
+
{/* Display Board */}
+
<div class="relative bg-gradient-to-b from-slate-800 to-slate-900 p-0.5 sm:p-1 rounded-lg shadow-[0_2px_10px_rgba(0,0,0,0.3)]">
+
{/* Metallic Frame */}
+
<div class="bg-gradient-to-b from-slate-600 via-slate-700 to-slate-800 p-1 sm:p-2 rounded-[6px]">
+
{/* Inner Frame */}
+
<div class="bg-black px-2 sm:px-4 py-0.5 rounded-[4px] relative overflow-hidden">
+
{/* Screen Background with Effects */}
+
<div class="absolute inset-0 bg-[#0a0a2f]">
+
{/* Scan lines */}
+
<div class="absolute inset-0 bg-[linear-gradient(transparent_0%,_rgba(255,255,255,0.02)_50%,_transparent_100%)] bg-[length:100%_4px]">
+
</div>
+
{/* Screen noise */}
+
<div class="absolute inset-0 opacity-[0.03] [background-image:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PC9maWx0ZXI+PHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iMC4wNSIvPjwvc3ZnPg==')]">
+
</div>
+
</div>
+
+
{/* Display Board Text */}
+
<div className="relative flex justify-center items-center py-0.5 sm:py-2 pb-1 sm:pb-4 px-1 sm:px-4">
+
<div className="relative text-center">
+
<span className="font-mono text-[1.5em] sm:text-[2em] md:text-[3em] font-semibold tracking-[0.12em] leading-[0.9] text-white
+
[text-shadow:0_0_20px_rgba(255,255,255,0.2),0_0_40px_rgba(255,255,255,0.1)]
+
relative z-10">
+
ATP INTERNECTIONAL AIRPORT
+
</span>
+
{/* Text glow effect */}
+
<div className="absolute inset-0 blur-[2px] opacity-50">
+
<span className="font-mono text-[1.5em] sm:text-[2em] md:text-[3em] font-semibold tracking-[0.12em] leading-[0.9] text-white">
+
ATP INTERNECTIONAL AIRPORT
+
</span>
+
</div>
+
</div>
+
</div>
+
+
{/* Screen reflection overlay */}
+
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/[0.03]">
+
</div>
+
{/* Vignette effect */}
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_0%,_rgba(0,0,0,0.2)_100%)]">
+
</div>
+
</div>
+
</div>
+
</div>
+
+
{/* Under Construction Extension */}
+
<div className="absolute left-1/2 top-full -translate-x-1/2">
+
<div className="bg-gradient-to-b from-slate-800 to-slate-900 pb-0.5 sm:pb-1 px-0.5 sm:px-1 rounded-b-lg rounded-t-none shadow-[0_2px_10px_rgba(0,0,0,0.3)] border-t-0">
+
<div className="bg-gradient-to-b from-slate-600 via-slate-700 to-slate-800 pb-1 sm:pb-2 px-1 sm:px-2 rounded-b-[6px] rounded-t-none border-t-0">
+
<div className="bg-black px-2 sm:px-4 pt-0 pb-0.5 rounded-b-[4px] rounded-t-none border-t-0">
+
<span className="font-mono text-xs sm:text-sm font-medium tracking-wider text-yellow-400 [text-shadow:0_0_10px_rgba(255,255,0,0.3)] animate-pulse">
+
UNDER CONSTRUCTION
+
</span>
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
);
+
}
+10
components/Button.tsx
···
type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>;
type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string };
+
/**
+
* The button props or anchor props for a button or link.
+
* @type {Props}
+
*/
type Props = ButtonProps | AnchorProps;
+
/**
+
* Styled button component.
+
* @param props - The button props
+
* @returns The button component
+
* @component
+
*/
export function Button(props: Props) {
const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props;
const isAnchor = 'href' in props;
+65
components/Link.tsx
···
+
import { JSX } from "preact";
+
+
/**
+
* Props for the Link component
+
*/
+
type Props = Omit<JSX.HTMLAttributes<HTMLAnchorElement>, "href"> & {
+
/** URL for the link */
+
href: string;
+
/** Whether this is an external link that should show an outbound icon */
+
isExternal?: boolean;
+
/** Link text content */
+
children: JSX.Element | string;
+
};
+
+
/**
+
* A link component that handles external links with appropriate styling and accessibility.
+
* Automatically adds external link icon and proper attributes for external links.
+
*/
+
export function Link(props: Props) {
+
const {
+
isExternal = false,
+
class: className = "",
+
children,
+
href,
+
...rest
+
} = props;
+
+
// SVG for external link icon
+
const externalLinkIcon = (
+
<svg
+
xmlns="http://www.w3.org/2000/svg"
+
viewBox="0 0 20 20"
+
fill="currentColor"
+
className="w-4 h-4 inline-block ml-1"
+
aria-hidden="true"
+
>
+
<path
+
fillRule="evenodd"
+
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"
+
/>
+
<path
+
fillRule="evenodd"
+
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"
+
/>
+
</svg>
+
);
+
+
return (
+
<a
+
href={href}
+
{...rest}
+
className={`inline-flex items-center hover:underline ${className}`}
+
{...(isExternal && {
+
target: "_blank",
+
rel: "noopener noreferrer",
+
"aria-label": `${
+
typeof children === "string" ? children : ""
+
} (opens in new tab)`,
+
})}
+
>
+
{children}
+
{isExternal && externalLinkIcon}
+
</a>
+
);
+
}
+34 -10
deno.json
···
"tasks": {
"check": "deno fmt --check . && deno lint . && deno check **/*.ts && deno check **/*.tsx",
"dev": "deno run -A --env --watch=static/,routes/ dev.ts",
-
"build": "deno run -A dev.ts build",
-
"start": "deno run -A main.ts",
+
"build": "deno run -A --unstable-otel dev.ts build",
+
"start": "deno run -A --unstable-otel main.ts",
"update": "deno run -A -r jsr:@fresh/update ."
},
"lint": {
"rules": {
-
"tags": ["fresh", "recommended"]
+
"tags": [
+
"fresh",
+
"recommended"
+
]
}
},
-
"exclude": ["**/_fresh/*"],
+
"exclude": [
+
"**/_fresh/*"
+
],
"imports": {
"@atproto/api": "npm:@atproto/api@^0.15.6",
"@bigmoves/atproto-oauth-client": "jsr:@bigmoves/atproto-oauth-client@^0.2.0",
···
"posthog-js": "npm:posthog-js@1.120.0",
"preact": "npm:preact@^10.26.6",
"@preact/signals": "npm:@preact/signals@^2.0.4",
-
"tailwindcss": "npm:tailwindcss@^3.4.3"
+
"tailwindcss": "npm:tailwindcss@^3.4.3",
+
"@atproto/crypto": "npm:@atproto/crypto@^0.4.4",
+
"@did-plc/lib": "npm:@did-plc/lib@^0.0.4"
},
"compilerOptions": {
-
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns"],
+
"lib": [
+
"dom",
+
"dom.asynciterable",
+
"dom.iterable",
+
"deno.ns"
+
],
"jsx": "precompile",
"jsxImportSource": "preact",
-
"jsxPrecompileSkipElements": ["a", "img", "source", "body", "html", "head"],
-
"types": ["node"]
+
"jsxPrecompileSkipElements": [
+
"a",
+
"img",
+
"source",
+
"body",
+
"html",
+
"head"
+
],
+
"types": [
+
"node"
+
]
},
-
"unstable": ["kv"]
-
}
+
"unstable": [
+
"kv",
+
"otel"
+
]
+
}
+284 -1
deno.lock
···
"npm:@atproto/api@*": "0.15.6",
"npm:@atproto/api@~0.15.6": "0.15.6",
"npm:@atproto/crypto@*": "0.4.4",
+
"npm:@atproto/crypto@~0.4.4": "0.4.4",
"npm:@atproto/identity@*": "0.4.8",
"npm:@atproto/jwk@0.1.4": "0.1.4",
"npm:@atproto/oauth-client@~0.3.13": "0.3.16",
"npm:@atproto/oauth-types@~0.2.4": "0.2.7",
"npm:@atproto/syntax@*": "0.4.0",
"npm:@atproto/xrpc@*": "0.7.0",
+
"npm:@did-plc/lib@^0.0.4": "0.0.4",
"npm:@lucide/lab@*": "0.1.2",
"npm:@opentelemetry/api@^1.9.0": "1.9.0",
"npm:@preact/signals@^1.2.3": "1.3.2_preact@10.26.6",
···
"zod"
]
},
+
"@atproto/common@0.1.1": {
+
"integrity": "sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==",
+
"dependencies": [
+
"@ipld/dag-cbor",
+
"multiformats@9.9.0",
+
"pino",
+
"zod"
+
]
+
},
+
"@atproto/crypto@0.1.0": {
+
"integrity": "sha512-9xgFEPtsCiJEPt9o3HtJT30IdFTGw5cQRSJVIy5CFhqBA4vDLcdXiRDLCjkzHEVbtNCsHUW6CrlfOgbeLPcmcg==",
+
"dependencies": [
+
"@noble/secp256k1",
+
"big-integer",
+
"multiformats@9.9.0",
+
"one-webcrypto",
+
"uint8arrays@3.0.0"
+
]
+
},
"@atproto/crypto@0.4.4": {
"integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==",
"dependencies": [
···
"integrity": "sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==",
"dependencies": [
"@atproto/common-web",
-
"@atproto/crypto"
+
"@atproto/crypto@0.4.4"
]
},
"@atproto/jwk@0.1.4": {
···
"integrity": "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==",
"dependencies": [
"@atproto/lexicon",
+
"zod"
+
]
+
},
+
"@did-plc/lib@0.0.4": {
+
"integrity": "sha512-Omeawq3b8G/c/5CtkTtzovSOnWuvIuCI4GTJNrt1AmCskwEQV7zbX5d6km1mjJNbE0gHuQPTVqZxLVqetNbfwA==",
+
"dependencies": [
+
"@atproto/common",
+
"@atproto/crypto@0.1.0",
+
"@ipld/dag-cbor",
+
"axios",
+
"multiformats@9.9.0",
+
"uint8arrays@3.0.0",
"zod"
]
},
···
"os": ["win32"],
"cpu": ["x64"]
},
+
"@ipld/dag-cbor@7.0.3": {
+
"integrity": "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA==",
+
"dependencies": [
+
"cborg",
+
"multiformats@9.9.0"
+
]
+
},
"@isaacs/cliui@8.0.2": {
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dependencies": [
···
},
"@noble/hashes@1.8.0": {
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
+
},
+
"@noble/secp256k1@1.7.2": {
+
"integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ=="
},
"@nodelib/fs.scandir@2.1.5": {
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
···
"undici-types"
]
},
+
"abort-controller@3.0.0": {
+
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+
"dependencies": [
+
"event-target-shim"
+
]
+
},
"ansi-regex@5.0.1": {
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
···
"arg@5.0.2": {
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
},
+
"asynckit@0.4.0": {
+
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+
},
+
"atomic-sleep@1.0.0": {
+
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="
+
},
"autoprefixer@10.4.17_postcss@8.4.35": {
"integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==",
"dependencies": [
···
"await-lock@2.2.2": {
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
},
+
"axios@1.10.0": {
+
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
+
"dependencies": [
+
"follow-redirects",
+
"form-data",
+
"proxy-from-env"
+
]
+
},
"balanced-match@1.0.2": {
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+
},
+
"base64-js@1.5.1": {
+
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
+
},
+
"big-integer@1.6.52": {
+
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="
},
"binary-extensions@2.3.0": {
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="
···
],
"bin": true
},
+
"buffer@6.0.3": {
+
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+
"dependencies": [
+
"base64-js",
+
"ieee754"
+
]
+
},
+
"call-bind-apply-helpers@1.0.2": {
+
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+
"dependencies": [
+
"es-errors",
+
"function-bind"
+
]
+
},
"camelcase-css@2.0.1": {
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="
},
···
},
"caniuse-lite@1.0.30001717": {
"integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw=="
+
},
+
"cborg@1.10.2": {
+
"integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==",
+
"bin": true
},
"chokidar@3.6.0": {
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
···
"colord@2.9.3": {
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
},
+
"combined-stream@1.0.8": {
+
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+
"dependencies": [
+
"delayed-stream"
+
]
+
},
"commander@4.1.1": {
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="
},
···
"css-tree@2.2.1"
]
},
+
"delayed-stream@1.0.0": {
+
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+
},
"didyoumean@1.2.2": {
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
},
···
"domhandler"
]
},
+
"dunder-proto@1.0.1": {
+
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+
"dependencies": [
+
"call-bind-apply-helpers",
+
"es-errors",
+
"gopd"
+
]
+
},
"eastasianwidth@0.2.0": {
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
},
···
"entities@4.5.0": {
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
},
+
"es-define-property@1.0.1": {
+
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
+
},
+
"es-errors@1.3.0": {
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
+
},
+
"es-object-atoms@1.1.1": {
+
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+
"dependencies": [
+
"es-errors"
+
]
+
},
+
"es-set-tostringtag@2.1.0": {
+
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+
"dependencies": [
+
"es-errors",
+
"get-intrinsic",
+
"has-tostringtag",
+
"hasown"
+
]
+
},
"esbuild-wasm@0.23.1": {
"integrity": "sha512-L3vn7ctvBrtScRfoB0zG1eOCiV4xYvpLYWfe6PDZuV+iDFDm4Mt3xeLIDllG8cDHQ8clUouK3XekulE+cxgkgw==",
"bin": true
···
"escalade@3.2.0": {
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
},
+
"event-target-shim@5.0.1": {
+
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
+
},
+
"events@3.3.0": {
+
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
+
},
"fast-glob@3.3.3": {
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dependencies": [
···
"micromatch"
]
},
+
"fast-redact@3.5.0": {
+
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="
+
},
"fastq@1.19.1": {
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dependencies": [
···
"to-regex-range"
]
},
+
"follow-redirects@1.15.9": {
+
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
+
},
"foreground-child@3.3.1": {
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dependencies": [
···
"signal-exit"
]
},
+
"form-data@4.0.3": {
+
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+
"dependencies": [
+
"asynckit",
+
"combined-stream",
+
"es-set-tostringtag",
+
"hasown",
+
"mime-types"
+
]
+
},
"fraction.js@4.3.7": {
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="
},
···
"function-bind@1.1.2": {
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
+
"get-intrinsic@1.3.0": {
+
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+
"dependencies": [
+
"call-bind-apply-helpers",
+
"es-define-property",
+
"es-errors",
+
"es-object-atoms",
+
"function-bind",
+
"get-proto",
+
"gopd",
+
"has-symbols",
+
"hasown",
+
"math-intrinsics"
+
]
+
},
+
"get-proto@1.0.1": {
+
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+
"dependencies": [
+
"dunder-proto",
+
"es-object-atoms"
+
]
+
},
"glob-parent@5.1.2": {
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": [
···
],
"bin": true
},
+
"gopd@1.2.0": {
+
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
+
},
"graphemer@1.4.0": {
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
},
+
"has-symbols@1.1.0": {
+
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
+
},
+
"has-tostringtag@1.0.2": {
+
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+
"dependencies": [
+
"has-symbols"
+
]
+
},
"hasown@2.0.2": {
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": [
"function-bind"
]
+
},
+
"ieee754@1.2.1": {
+
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"ipaddr.js@2.2.0": {
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="
···
"preact@10.26.6"
},
+
"math-intrinsics@1.1.0": {
+
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
+
},
"mdn-data@2.0.28": {
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="
},
···
"picomatch"
},
+
"mime-db@1.52.0": {
+
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+
},
+
"mime-types@2.1.35": {
+
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+
"dependencies": [
+
"mime-db"
+
]
+
},
"minimatch@9.0.5": {
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": [
···
"object-hash@3.0.0": {
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="
},
+
"on-exit-leak-free@2.1.2": {
+
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="
+
},
+
"one-webcrypto@1.0.3": {
+
"integrity": "sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q=="
+
},
"package-json-from-dist@1.0.1": {
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
···
"pify@2.3.0": {
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="
},
+
"pino-abstract-transport@1.2.0": {
+
"integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==",
+
"dependencies": [
+
"readable-stream",
+
"split2"
+
]
+
},
+
"pino-std-serializers@6.2.2": {
+
"integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="
+
},
+
"pino@8.21.0": {
+
"integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==",
+
"dependencies": [
+
"atomic-sleep",
+
"fast-redact",
+
"on-exit-leak-free",
+
"pino-abstract-transport",
+
"pino-std-serializers",
+
"process-warning",
+
"quick-format-unescaped",
+
"real-require",
+
"safe-stable-stringify",
+
"sonic-boom",
+
"thread-stream"
+
],
+
"bin": true
+
},
"pirates@4.0.7": {
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="
},
···
"preact@10.26.7": {
"integrity": "sha512-43xS+QYc1X1IPbw03faSgY6I6OYWcLrJRv3hU0+qMOfh/XCHcP0MX2CVjNARYR2cC/guu975sta4OcjlczxD7g=="
},
+
"process-warning@3.0.0": {
+
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="
+
},
+
"process@0.11.10": {
+
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
+
},
+
"proxy-from-env@1.1.0": {
+
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+
},
"psl@1.15.0": {
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dependencies": [
···
"queue-microtask@1.2.3": {
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
+
"quick-format-unescaped@4.0.4": {
+
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
+
},
"read-cache@1.0.0": {
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dependencies": [
"pify"
},
+
"readable-stream@4.7.0": {
+
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+
"dependencies": [
+
"abort-controller",
+
"buffer",
+
"events",
+
"process",
+
"string_decoder"
+
]
+
},
"readdirp@3.6.0": {
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dependencies": [
"picomatch"
+
},
+
"real-require@0.2.0": {
+
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
},
"resolve@1.22.10": {
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
···
"queue-microtask"
},
+
"safe-buffer@5.2.1": {
+
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
+
},
+
"safe-stable-stringify@2.5.0": {
+
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
+
},
"shebang-command@2.0.0": {
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dependencies": [
···
},
"signal-exit@4.1.0": {
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
+
},
+
"sonic-boom@3.8.1": {
+
"integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==",
+
"dependencies": [
+
"atomic-sleep"
+
]
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
+
"split2@4.2.0": {
+
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
+
},
"string-width@4.2.3": {
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": [
···
"eastasianwidth",
"emoji-regex@9.2.2",
"strip-ansi@7.1.0"
+
]
+
},
+
"string_decoder@1.3.0": {
+
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+
"dependencies": [
+
"safe-buffer"
},
"strip-ansi@6.0.1": {
···
"any-promise"
},
+
"thread-stream@2.7.0": {
+
"integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==",
+
"dependencies": [
+
"real-require"
+
]
+
},
"tlds@1.258.0": {
"integrity": "sha512-XGhStWuOlBA5D8QnyN2xtgB2cUOdJ3ztisne1DYVWMcVH29qh8eQIpRmP3HnuJLdgyzG0HpdGzRMu1lm/Oictw==",
"bin": true
···
"jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7",
"jsr:@knotbin/posthog-fresh@~0.1.3",
"npm:@atproto/api@~0.15.6",
+
"npm:@atproto/crypto@~0.4.4",
+
"npm:@did-plc/lib@^0.0.4",
"npm:@preact/signals@^2.0.4",
"npm:posthog-js@1.120.0",
"npm:preact@^10.26.6",
-67
islands/AirportSign.tsx
···
-
export default function AirportSign() {
-
return (
-
<div class="relative inline-block mb-12">
-
{/* Left Pole */}
-
<div class="absolute left-8 -top-24 w-4 h-24 bg-gradient-to-r from-slate-800 via-slate-600 to-slate-800 rounded-t-lg">
-
</div>
-
{/* Right Pole */}
-
<div class="absolute right-8 -top-24 w-4 h-24 bg-gradient-to-r from-slate-800 via-slate-600 to-slate-800 rounded-t-lg">
-
</div>
-
{/* Display Board */}
-
<div class="relative bg-gradient-to-b from-slate-800 to-slate-900 p-1 rounded-lg shadow-[0_2px_10px_rgba(0,0,0,0.3)]">
-
{/* Metallic Frame */}
-
<div class="bg-gradient-to-b from-slate-600 via-slate-700 to-slate-800 p-2 rounded-[6px]">
-
{/* Inner Frame */}
-
<div class="bg-black px-4 py-0.5 rounded-[4px] relative overflow-hidden">
-
{/* Screen Background with Effects */}
-
<div class="absolute inset-0 bg-[#0a0a2f]">
-
{/* Scan lines */}
-
<div class="absolute inset-0 bg-[linear-gradient(transparent_0%,_rgba(255,255,255,0.02)_50%,_transparent_100%)] bg-[length:100%_4px]">
-
</div>
-
{/* Screen noise */}
-
<div class="absolute inset-0 opacity-[0.03] [background-image:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PC9maWx0ZXI+PHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iMC4wNSIvPjwvc3ZnPg==')]">
-
</div>
-
</div>
-
-
{/* Display Board Text */}
-
<div className="relative flex justify-center items-center py-1 sm:py-2 pb-2 sm:pb-4 px-2 sm:px-4">
-
<div className="relative text-center">
-
<span className="font-mono text-[2em] sm:text-[3em] font-semibold tracking-[0.12em] leading-[0.9] text-white
-
[text-shadow:0_0_20px_rgba(255,255,255,0.2),0_0_40px_rgba(255,255,255,0.1)]
-
relative z-10">
-
ATP INTERNECTIONAL AIRPORT
-
</span>
-
{/* Text glow effect */}
-
<div className="absolute inset-0 blur-[2px] opacity-50">
-
<span className="font-mono text-[2em] sm:text-[3em] font-semibold tracking-[0.12em] leading-[0.9] text-white">
-
ATP INTERNECTIONAL AIRPORT
-
</span>
-
</div>
-
</div>
-
</div>
-
-
{/* Screen reflection overlay */}
-
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/[0.03]">
-
</div>
-
{/* Vignette effect */}
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_0%,_rgba(0,0,0,0.2)_100%)]">
-
</div>
-
</div>
-
</div>
-
</div>
-
-
{/* Under Construction Extension BELOW the sign, perfectly matching main sign frame, no outline or padding on top */}
-
<div className="absolute left-1/2 top-full -translate-x-1/2">
-
<div className="bg-gradient-to-b from-slate-800 to-slate-900 pb-1 px-1 rounded-b-lg rounded-t-none shadow-[0_2px_10px_rgba(0,0,0,0.3)] border-t-0">
-
<div className="bg-gradient-to-b from-slate-600 via-slate-700 to-slate-800 pb-2 px-2 rounded-b-[6px] rounded-t-none border-t-0">
-
<div className="bg-black px-4 pt-0 pb-0.5 rounded-b-[4px] rounded-t-none border-t-0">
-
<span className="font-mono text-sm font-medium tracking-wider text-yellow-400 [text-shadow:0_0_10px_rgba(255,255,0,0.3)] animate-pulse">
-
UNDER CONSTRUCTION
-
</span>
-
</div>
-
</div>
-
</div>
-
</div>
-
</div>
-
);
-
}
+41 -28
islands/CredLogin.tsx
···
-
import { useState } from 'preact/hooks'
-
import { JSX } from 'preact'
+
import { useState } from "preact/hooks";
+
import { JSX } from "preact";
+
/**
+
* The credential login form.
+
* @returns The credential login form
+
* @component
+
*/
export default function CredLogin() {
-
const [handle, setHandle] = useState('')
-
const [password, setPassword] = useState('')
-
const [error, setError] = useState<string | null>(null)
-
const [isPending, setIsPending] = useState(false)
+
const [handle, setHandle] = useState("");
+
const [password, setPassword] = useState("");
+
const [error, setError] = useState<string | null>(null);
+
const [isPending, setIsPending] = useState(false);
const handleSubmit = async (e: JSX.TargetedEvent<HTMLFormElement>) => {
-
e.preventDefault()
-
if (!handle.trim() || !password.trim()) return
+
e.preventDefault();
+
if (!handle.trim() || !password.trim()) return;
-
setError(null)
-
setIsPending(true)
+
setError(null);
+
setIsPending(true);
try {
-
const response = await fetch('/api/cred/login', {
-
method: 'POST',
+
const response = await fetch("/api/cred/login", {
+
method: "POST",
headers: {
-
'Content-Type': 'application/json',
+
"Content-Type": "application/json",
},
body: JSON.stringify({ handle, password }),
-
})
+
});
if (!response.ok) {
-
const errorText = await response.text()
-
throw new Error(errorText || 'Login failed')
+
const errorText = await response.text();
+
throw new Error(errorText || "Login failed");
}
// Add a small delay before redirecting for better UX
-
await new Promise((resolve) => setTimeout(resolve, 500))
+
await new Promise((resolve) => setTimeout(resolve, 500));
// Redirect to home page after successful login
-
globalThis.location.href = '/'
+
globalThis.location.href = "/";
} catch (err) {
-
const message = err instanceof Error ? err.message : 'Login failed'
-
setError(message)
+
const message = err instanceof Error ? err.message : "Login failed";
+
setError(message);
} finally {
-
setIsPending(false)
+
setIsPending(false);
}
-
}
+
};
return (
<form onSubmit={handleSubmit}>
···
className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
-
This is your main account password, not an app password. This is required for migrations.
+
This is your main account password, not an app password. This is
+
required for migrations.
+
<br />
+
Ensure that 2 Factor Authentication is turned off before proceeding.
+
You can turn it back on after the migration is complete.
</p>
</div>
···
type="submit"
disabled={isPending}
className={`w-full px-4 py-2 rounded-md bg-blue-500 dark:bg-blue-600 text-white font-medium hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 relative ${
-
isPending ? 'opacity-90 cursor-not-allowed' : ''
+
isPending ? "opacity-90 cursor-not-allowed" : ""
}`}
>
-
<span className={isPending ? 'invisible' : ''}>Login with Password</span>
+
<span className={isPending ? "invisible" : ""}>
+
Login with Password
+
</span>
{isPending && (
<span className="absolute inset-0 flex items-center justify-center">
<svg
···
r="10"
stroke="currentColor"
strokeWidth="4"
-
></circle>
+
>
+
</circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
-
></path>
+
>
+
</path>
</svg>
<span>Logging in...</span>
</span>
)}
</button>
</form>
-
)
+
);
}
+1136
islands/DidPlcProgress.tsx
···
+
import { useState, useEffect } from "preact/hooks";
+
import { Link } from "../components/Link.tsx";
+
+
interface PlcUpdateStep {
+
name: string;
+
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
+
error?: string;
+
}
+
+
// Content chunks for the description
+
const contentChunks = [
+
{
+
title: "Welcome to Key Management",
+
subtitle: "BOARDING PASS - SECTION A",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-01 โ€ข SEAT: DID-1A
+
</div>
+
<p class="text-slate-700 dark:text-slate-300 mb-4">
+
This tool helps you add a new rotation key to your{" "}
+
<Link
+
href="https://web.plc.directory/"
+
isExternal
+
class="text-blue-600 dark:text-blue-400"
+
>
+
PLC (Public Ledger of Credentials)
+
</Link>
+
. Having control of a rotation key gives you sovereignty over your DID
+
(Decentralized Identifier).
+
</p>
+
</>
+
),
+
},
+
{
+
title: "Key Benefits",
+
subtitle: "BOARDING PASS - SECTION B",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-02 โ€ข SEAT: DID-1B
+
</div>
+
<div class="space-y-4">
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2">
+
PROVIDER MOBILITY โœˆ๏ธ
+
</h4>
+
<p class="text-slate-700 dark:text-slate-300">
+
Change your PDS without losing your identity, protecting you if
+
your provider becomes hostile.
+
</p>
+
</div>
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2">
+
IDENTITY CONTROL โœจ
+
</h4>
+
<p class="text-slate-700 dark:text-slate-300">
+
Modify your DID document independently of your provider.
+
</p>
+
</div>
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<p class="text-slate-700 dark:text-slate-300">
+
๐Ÿ’ก It's good practice to have a rotation key so you can move to a
+
different provider if you need to.
+
</p>
+
</div>
+
</div>
+
</>
+
),
+
},
+
{
+
title: "โš ๏ธ CRITICAL SECURITY WARNING",
+
subtitle: "BOARDING PASS - SECTION C",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-03 โ€ข SEAT: DID-1C
+
</div>
+
<div class="p-4 bg-red-50 dark:bg-red-900 rounded-lg border-2 border-red-500 dark:border-red-700 mb-4">
+
<div class="flex items-center mb-3">
+
<span class="text-2xl mr-2">โš ๏ธ</span>
+
<h4 class="font-mono font-bold text-red-700 dark:text-red-400 text-lg">
+
NON-REVOCABLE KEY WARNING
+
</h4>
+
</div>
+
<div class="space-y-3 text-red-700 dark:text-red-300">
+
<p class="font-bold">
+
This rotation key CANNOT BE DISABLED OR DELETED once added:
+
</p>
+
<ul class="list-disc pl-5 space-y-2">
+
<li>
+
If compromised, the attacker can take complete control of your
+
account and identity
+
</li>
+
<li>
+
Malicious actors with this key have COMPLETE CONTROL of your
+
account and identity
+
</li>
+
<li>
+
Store securely, like a password (e.g. <strong>DO NOT</strong>{" "}
+
keep it in Notes or any easily accessible app on an unlocked
+
device).
+
</li>
+
</ul>
+
</div>
+
</div>
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<p class="text-slate-700 dark:text-slate-300">
+
๐Ÿ’ก We recommend adding a custom rotation key but recommend{" "}
+
<strong class="italic">against</strong> having more than one custom
+
rotation key, as more than one increases risk.
+
</p>
+
</div>
+
</>
+
),
+
},
+
{
+
title: "Technical Overview",
+
subtitle: "BOARDING PASS - SECTION C",
+
content: (
+
<>
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
+
GATE: KEY-03 โ€ข SEAT: DID-1C
+
</div>
+
<div class="p-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
+
<div class="flex items-center mb-3">
+
<span class="text-lg mr-2">๐Ÿ“</span>
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400">
+
TECHNICAL DETAILS
+
</h4>
+
</div>
+
<p class="text-slate-700 dark:text-slate-300">
+
The rotation key is a did:key that will be added to your PLC
+
document's rotationKeys array. This process uses the AT Protocol's
+
PLC operations to update your DID document.
+
<Link
+
href="https://web.plc.directory/"
+
class="block ml-1 text-blue-600 dark:text-blue-400"
+
isExternal
+
>
+
Learn more about did:plc
+
</Link>
+
</p>
+
</div>
+
</>
+
),
+
},
+
];
+
+
export default function PlcUpdateProgress() {
+
const [hasStarted, setHasStarted] = useState(false);
+
const [currentChunkIndex, setCurrentChunkIndex] = useState(0);
+
const [steps, setSteps] = useState<PlcUpdateStep[]>([
+
{ name: "Generate Rotation Key", status: "pending" },
+
{ name: "Start PLC update", status: "pending" },
+
{ name: "Complete PLC update", status: "pending" },
+
]);
+
const [generatedKey, setGeneratedKey] = useState<string>("");
+
const [keyJson, setKeyJson] = useState<any>(null);
+
const [emailToken, setEmailToken] = useState<string>("");
+
const [updateResult, setUpdateResult] = useState<string>("");
+
const [showDownload, setShowDownload] = useState(false);
+
const [showKeyInfo, setShowKeyInfo] = useState(false);
+
const [hasDownloadedKey, setHasDownloadedKey] = useState(false);
+
const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null);
+
+
const updateStepStatus = (
+
index: number,
+
status: PlcUpdateStep["status"],
+
error?: string
+
) => {
+
console.log(
+
`Updating step ${index} to ${status}${
+
error ? ` with error: ${error}` : ""
+
}`
+
);
+
setSteps((prevSteps) =>
+
prevSteps.map((step, i) =>
+
i === index
+
? { ...step, status, error }
+
: i > index
+
? { ...step, status: "pending", error: undefined }
+
: step
+
)
+
);
+
};
+
+
const handleStart = () => {
+
setHasStarted(true);
+
// Automatically start the first step
+
setTimeout(() => {
+
handleGenerateKey();
+
}, 100);
+
};
+
+
const getStepDisplayName = (step: PlcUpdateStep, index: number) => {
+
if (step.status === "completed") {
+
switch (index) {
+
case 0:
+
return "Rotation Key Generated";
+
case 1:
+
return "PLC Operation Requested";
+
case 2:
+
return "PLC Update Completed";
+
}
+
}
+
+
if (step.status === "in-progress") {
+
switch (index) {
+
case 0:
+
return "Generating Rotation Key...";
+
case 1:
+
return "Requesting PLC Operation Token...";
+
case 2:
+
return step.name ===
+
"Enter the code sent to your email to complete PLC update"
+
? step.name
+
: "Completing PLC Update...";
+
}
+
}
+
+
if (step.status === "verifying") {
+
switch (index) {
+
case 0:
+
return "Verifying Rotation Key Generation...";
+
case 1:
+
return "Verifying PLC Operation Token Request...";
+
case 2:
+
return "Verifying PLC Update Completion...";
+
}
+
}
+
+
return step.name;
+
};
+
+
const handleStartPlcUpdate = async (keyToUse?: string) => {
+
const key = keyToUse || generatedKey;
+
+
// Debug logging
+
console.log("=== PLC Update Debug ===");
+
console.log("Current state:", {
+
keyToUse,
+
generatedKey,
+
key,
+
hasKeyJson: !!keyJson,
+
keyJsonId: keyJson?.publicKeyDid,
+
hasDownloadedKey,
+
downloadedKeyId,
+
steps: steps.map((s) => ({ name: s.name, status: s.status })),
+
});
+
+
if (!key) {
+
console.log("No key generated yet");
+
updateStepStatus(1, "error", "No key generated yet");
+
return;
+
}
+
+
if (!keyJson || keyJson.publicKeyDid !== key) {
+
console.log("Key mismatch or missing:", {
+
hasKeyJson: !!keyJson,
+
keyJsonId: keyJson?.publicKeyDid,
+
expectedKey: key,
+
});
+
updateStepStatus(
+
1,
+
"error",
+
"Please ensure you have the correct key loaded"
+
);
+
return;
+
}
+
+
updateStepStatus(1, "in-progress");
+
try {
+
// First request the token
+
console.log("Requesting PLC token...");
+
const tokenRes = await fetch("/api/plc/token", {
+
method: "GET",
+
});
+
const tokenText = await tokenRes.text();
+
console.log("Token response:", tokenText);
+
+
if (!tokenRes.ok) {
+
try {
+
const json = JSON.parse(tokenText);
+
throw new Error(json.message || "Failed to request PLC token");
+
} catch {
+
throw new Error(tokenText || "Failed to request PLC token");
+
}
+
}
+
+
let data;
+
try {
+
data = JSON.parse(tokenText);
+
if (!data.success) {
+
throw new Error(data.message || "Failed to request token");
+
}
+
} catch (error) {
+
throw new Error("Invalid response from server");
+
}
+
+
console.log("Token request successful, updating UI...");
+
// Update step name to prompt for token
+
setSteps((prevSteps) =>
+
prevSteps.map((step, i) =>
+
i === 1
+
? {
+
...step,
+
name: "Enter the code sent to your email to complete PLC update",
+
status: "in-progress",
+
}
+
: step
+
)
+
);
+
} catch (error) {
+
console.error("Token request failed:", error);
+
updateStepStatus(
+
1,
+
"error",
+
error instanceof Error ? error.message : String(error)
+
);
+
}
+
};
+
+
const handleTokenSubmit = async () => {
+
console.log("=== Token Submit Debug ===");
+
console.log("Current state:", {
+
emailToken,
+
generatedKey,
+
keyJsonId: keyJson?.publicKeyDid,
+
steps: steps.map((s) => ({ name: s.name, status: s.status })),
+
});
+
+
if (!emailToken) {
+
console.log("No token provided");
+
updateStepStatus(1, "error", "Please enter the email token");
+
return;
+
}
+
+
if (!keyJson || !keyJson.publicKeyDid) {
+
console.log("Missing key data");
+
updateStepStatus(1, "error", "Key data is missing, please try again");
+
return;
+
}
+
+
// Prevent duplicate submissions
+
if (steps[1].status === "completed" || steps[2].status === "completed") {
+
console.log("Update already completed, preventing duplicate submission");
+
return;
+
}
+
+
updateStepStatus(1, "completed");
+
try {
+
updateStepStatus(2, "in-progress");
+
console.log("Submitting update request with token...");
+
// Send the update request with both key and token
+
const res = await fetch("/api/plc/update", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
key: keyJson.publicKeyDid,
+
token: emailToken,
+
}),
+
});
+
const text = await res.text();
+
console.log("Update response:", text);
+
+
let data;
+
try {
+
data = JSON.parse(text);
+
} catch {
+
throw new Error("Invalid response from server");
+
}
+
+
// Check for error responses
+
if (!res.ok || !data.success) {
+
const errorMessage = data.message || "Failed to complete PLC update";
+
console.error("Update failed:", errorMessage);
+
throw new Error(errorMessage);
+
}
+
+
// Only proceed if we have a successful response
+
console.log("Update completed successfully!");
+
setUpdateResult("PLC update completed successfully!");
+
+
// Add a delay before marking steps as completed for better UX
+
updateStepStatus(2, "verifying");
+
+
const verifyRes = await fetch("/api/plc/verify", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
key: keyJson.publicKeyDid,
+
}),
+
});
+
+
const verifyText = await verifyRes.text();
+
console.log("Verification response:", verifyText);
+
+
let verifyData;
+
try {
+
verifyData = JSON.parse(verifyText);
+
} catch {
+
throw new Error("Invalid verification response from server");
+
}
+
+
if (!verifyRes.ok || !verifyData.success) {
+
const errorMessage =
+
verifyData.message || "Failed to verify PLC update";
+
console.error("Verification failed:", errorMessage);
+
throw new Error(errorMessage);
+
}
+
+
console.log("Verification successful, marking steps as completed");
+
updateStepStatus(2, "completed");
+
} catch (error) {
+
console.error("Update failed:", error);
+
// Reset the steps to error state
+
updateStepStatus(
+
1,
+
"error",
+
error instanceof Error ? error.message : String(error)
+
);
+
updateStepStatus(2, "pending"); // Reset the final step
+
setUpdateResult(error instanceof Error ? error.message : String(error));
+
+
// If token is invalid, we should clear it so user can try again
+
if (
+
error instanceof Error &&
+
error.message.toLowerCase().includes("token is invalid")
+
) {
+
setEmailToken("");
+
}
+
}
+
};
+
+
const handleCompletePlcUpdate = async () => {
+
// This function is no longer needed as we handle everything in handleTokenSubmit
+
return;
+
};
+
+
const handleDownload = () => {
+
console.log("=== Download Debug ===");
+
console.log("Download started with:", {
+
hasKeyJson: !!keyJson,
+
keyJsonId: keyJson?.publicKeyDid,
+
});
+
+
if (!keyJson) {
+
console.error("No key JSON to download");
+
return;
+
}
+
+
try {
+
const jsonString = JSON.stringify(keyJson, null, 2);
+
const blob = new Blob([jsonString], {
+
type: "application/json",
+
});
+
const url = URL.createObjectURL(blob);
+
const a = document.createElement("a");
+
a.href = url;
+
a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`;
+
a.style.display = "none";
+
document.body.appendChild(a);
+
a.click();
+
document.body.removeChild(a);
+
URL.revokeObjectURL(url);
+
+
console.log("Download completed, proceeding to next step...");
+
setHasDownloadedKey(true);
+
setDownloadedKeyId(keyJson.publicKeyDid);
+
+
// Automatically proceed to the next step after successful download
+
setTimeout(() => {
+
console.log("Auto-proceeding with key:", keyJson.publicKeyDid);
+
handleStartPlcUpdate(keyJson.publicKeyDid);
+
}, 1000);
+
} catch (error) {
+
console.error("Download failed:", error);
+
}
+
};
+
+
const handleGenerateKey = async () => {
+
console.log("=== Generate Key Debug ===");
+
updateStepStatus(0, "in-progress");
+
setShowDownload(false);
+
setKeyJson(null);
+
setGeneratedKey("");
+
setHasDownloadedKey(false);
+
setDownloadedKeyId(null);
+
+
try {
+
console.log("Requesting new key...");
+
const res = await fetch("/api/plc/keys");
+
const text = await res.text();
+
console.log("Key generation response:", text);
+
+
if (!res.ok) {
+
try {
+
const json = JSON.parse(text);
+
throw new Error(json.message || "Failed to generate key");
+
} catch {
+
throw new Error(text || "Failed to generate key");
+
}
+
}
+
+
let data;
+
try {
+
data = JSON.parse(text);
+
} catch {
+
throw new Error("Invalid response from /api/plc/keys");
+
}
+
+
if (!data.publicKeyDid || !data.privateKeyHex) {
+
throw new Error("Key generation failed: missing key data");
+
}
+
+
console.log("Key generated successfully:", {
+
keyId: data.publicKeyDid,
+
});
+
+
setGeneratedKey(data.publicKeyDid);
+
setKeyJson(data);
+
setShowDownload(true);
+
updateStepStatus(0, "completed");
+
} catch (error) {
+
console.error("Key generation failed:", error);
+
updateStepStatus(
+
0,
+
"error",
+
error instanceof Error ? error.message : String(error)
+
);
+
}
+
};
+
+
const getStepIcon = (status: PlcUpdateStep["status"]) => {
+
switch (status) {
+
case "pending":
+
return (
+
<div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
+
<div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
+
</div>
+
);
+
case "in-progress":
+
return (
+
<div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
+
<div class="w-3 h-3 rounded-full bg-blue-500" />
+
</div>
+
);
+
case "verifying":
+
return (
+
<div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
+
<div class="w-3 h-3 rounded-full bg-yellow-500" />
+
</div>
+
);
+
case "completed":
+
return (
+
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
+
<svg
+
class="w-5 h-5 text-white"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M5 13l4 4L19 7"
+
/>
+
</svg>
+
</div>
+
);
+
case "error":
+
return (
+
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
+
<svg
+
class="w-5 h-5 text-white"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M6 18L18 6M6 6l12 12"
+
/>
+
</svg>
+
</div>
+
);
+
}
+
};
+
+
const getStepClasses = (status: PlcUpdateStep["status"]) => {
+
const baseClasses =
+
"flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
+
switch (status) {
+
case "pending":
+
return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
+
case "in-progress":
+
return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
+
case "verifying":
+
return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
+
case "completed":
+
return `${baseClasses} bg-green-50 dark:bg-green-900`;
+
case "error":
+
return `${baseClasses} bg-red-50 dark:bg-red-900`;
+
}
+
};
+
+
const requestNewToken = async () => {
+
try {
+
console.log("Requesting new token...");
+
const res = await fetch("/api/plc/token", {
+
method: "GET",
+
});
+
const text = await res.text();
+
console.log("Token request response:", text);
+
+
if (!res.ok) {
+
throw new Error(text || "Failed to request new token");
+
}
+
+
let data;
+
try {
+
data = JSON.parse(text);
+
if (!data.success) {
+
throw new Error(data.message || "Failed to request token");
+
}
+
} catch {
+
throw new Error("Invalid response from server");
+
}
+
+
// Clear any existing error and token
+
setEmailToken("");
+
updateStepStatus(1, "in-progress");
+
updateStepStatus(2, "pending");
+
} catch (error) {
+
console.error("Failed to request new token:", error);
+
updateStepStatus(
+
1,
+
"error",
+
error instanceof Error ? error.message : String(error)
+
);
+
}
+
};
+
+
if (!hasStarted) {
+
return (
+
<div class="space-y-6">
+
<div class="ticket bg-white dark:bg-slate-800 p-6 relative">
+
<div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-2">
+
{contentChunks[currentChunkIndex].subtitle}
+
</div>
+
+
<div class="flex justify-between items-start mb-4">
+
<h3 class="text-2xl font-mono text-slate-800 dark:text-slate-200">
+
{contentChunks[currentChunkIndex].title}
+
</h3>
+
</div>
+
+
{/* Main Description */}
+
<div class="mb-6">{contentChunks[currentChunkIndex].content}</div>
+
+
{/* Navigation */}
+
<div class="mt-8 border-t border-dashed border-slate-200 dark:border-slate-700 pt-4">
+
<div class="flex justify-between items-center">
+
<button
+
onClick={() =>
+
setCurrentChunkIndex((prev) => Math.max(0, prev - 1))
+
}
+
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 ${
+
currentChunkIndex === 0 ? "invisible" : ""
+
}`}
+
>
+
<svg
+
class="w-5 h-5 rotate-180"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M9 5l7 7-7 7"
+
/>
+
</svg>
+
<span>Previous Gate</span>
+
</button>
+
+
{currentChunkIndex === contentChunks.length - 1 ? (
+
<button
+
onClick={handleStart}
+
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"
+
>
+
<span>Begin Key Generation</span>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M9 5l7 7-7 7"
+
/>
+
</svg>
+
</button>
+
) : (
+
<button
+
onClick={() =>
+
setCurrentChunkIndex((prev) =>
+
Math.min(contentChunks.length - 1, prev + 1)
+
)
+
}
+
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"
+
>
+
<span>Next Gate</span>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M9 5l7 7-7 7"
+
/>
+
</svg>
+
</button>
+
)}
+
</div>
+
+
{/* Progress Dots */}
+
<div class="flex justify-center space-x-3 mt-4">
+
{contentChunks.map((_, index) => (
+
<div
+
key={index}
+
class={`h-1.5 w-8 rounded-full transition-colors duration-200 ${
+
index === currentChunkIndex
+
? "bg-amber-500"
+
: "bg-slate-200 dark:bg-slate-700"
+
}`}
+
/>
+
))}
+
</div>
+
</div>
+
</div>
+
</div>
+
);
+
}
+
+
return (
+
<div class="space-y-8">
+
{/* Progress Steps */}
+
<div class="space-y-4">
+
<div class="flex items-center justify-between">
+
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
+
Key Generation Progress
+
</h3>
+
{/* Add a help tooltip */}
+
<div class="relative group">
+
<button class="text-gray-400 hover:text-gray-500">
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+
/>
+
</svg>
+
</button>
+
<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">
+
<p class="text-gray-600 dark:text-gray-400">
+
Follow these steps to securely add a new rotation key to your
+
PLC record. Each step requires completion before proceeding.
+
</p>
+
</div>
+
</div>
+
</div>
+
+
{/* Steps with enhanced visual hierarchy */}
+
{steps.map((step, index) => (
+
<div
+
key={step.name}
+
class={`${getStepClasses(step.status)} ${
+
step.status === "in-progress"
+
? "ring-2 ring-blue-500 ring-opacity-50"
+
: ""
+
}`}
+
>
+
<div class="flex-shrink-0">{getStepIcon(step.status)}</div>
+
<div class="flex-1 min-w-0">
+
<div class="flex items-center justify-between">
+
<p
+
class={`font-medium ${
+
step.status === "error"
+
? "text-red-900 dark:text-red-200"
+
: step.status === "completed"
+
? "text-green-900 dark:text-green-200"
+
: step.status === "in-progress"
+
? "text-blue-900 dark:text-blue-200"
+
: "text-gray-900 dark:text-gray-200"
+
}`}
+
>
+
{getStepDisplayName(step, index)}
+
</p>
+
{/* Add step number */}
+
<span class="text-sm text-gray-500 dark:text-gray-400">
+
Step {index + 1} of {steps.length}
+
</span>
+
</div>
+
+
{step.error && (
+
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md">
+
<p class="text-sm text-red-600 dark:text-red-400 flex items-center">
+
<svg
+
class="w-4 h-4 mr-1"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+
/>
+
</svg>
+
{(() => {
+
try {
+
const err = JSON.parse(step.error);
+
return err.message || step.error;
+
} catch {
+
return step.error;
+
}
+
})()}
+
</p>
+
</div>
+
)}
+
+
{/* Key Download Warning */}
+
{index === 0 &&
+
step.status === "completed" &&
+
!hasDownloadedKey && (
+
<div class="mt-4 space-y-4">
+
<div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
+
<div class="flex items-start">
+
<div class="flex-shrink-0">
+
<svg
+
class="h-5 w-5 text-yellow-400"
+
viewBox="0 0 20 20"
+
fill="currentColor"
+
>
+
<path
+
fill-rule="evenodd"
+
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"
+
clip-rule="evenodd"
+
/>
+
</svg>
+
</div>
+
<div class="ml-3">
+
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
+
Critical Security Step
+
</h3>
+
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
+
<p class="mb-2">
+
Your rotation key grants control over your
+
identity:
+
</p>
+
<ul class="list-disc pl-5 space-y-2">
+
<li>
+
<strong>Store Securely:</strong> Use a password
+
manager
+
</li>
+
<li>
+
<strong>Keep Private:</strong> Never share with
+
anyone
+
</li>
+
<li>
+
<strong>Backup:</strong> Keep a secure backup
+
copy
+
</li>
+
<li>
+
<strong>Required:</strong> Needed for future DID
+
modifications
+
</li>
+
</ul>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<div class="flex items-center justify-between">
+
<button
+
onClick={handleDownload}
+
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"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
+
/>
+
</svg>
+
<span>Download Key</span>
+
</button>
+
+
<div class="flex items-center text-sm text-red-600 dark:text-red-400">
+
<svg
+
class="w-4 h-4 mr-1"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<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"
+
/>
+
</svg>
+
Download required to proceed
+
</div>
+
</div>
+
</div>
+
)}
+
+
{/* Email Code Input */}
+
{index === 1 &&
+
(step.status === "in-progress" ||
+
step.status === "verifying") &&
+
step.name ===
+
"Enter the code sent to your email to complete PLC update" && (
+
<div class="mt-4 space-y-4">
+
<div class="bg-blue-50 dark:bg-blue-900/50 p-4 rounded-lg">
+
<p class="text-sm text-blue-800 dark:text-blue-200 mb-3">
+
Check your email for the verification code to complete
+
the PLC update:
+
</p>
+
<div class="flex space-x-2">
+
<div class="flex-1 relative">
+
<input
+
type="text"
+
value={emailToken}
+
onChange={(e) =>
+
setEmailToken(e.currentTarget.value)
+
}
+
placeholder="Enter verification code"
+
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"
+
/>
+
</div>
+
<button
+
type="button"
+
onClick={handleTokenSubmit}
+
disabled={!emailToken || step.status === "verifying"}
+
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"
+
>
+
<span>
+
{step.status === "verifying"
+
? "Verifying..."
+
: "Verify"}
+
</span>
+
<svg
+
class="w-4 h-4"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
+
/>
+
</svg>
+
</button>
+
</div>
+
{step.error && (
+
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md">
+
<p class="text-sm text-red-600 dark:text-red-400 flex items-center">
+
<svg
+
class="w-4 h-4 mr-1"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+
/>
+
</svg>
+
{step.error}
+
</p>
+
{step.error
+
.toLowerCase()
+
.includes("token is invalid") && (
+
<div class="mt-2">
+
<p class="text-sm text-red-500 dark:text-red-300 mb-2">
+
The verification code may have expired. Request
+
a new code to try again.
+
</p>
+
<button
+
onClick={requestNewToken}
+
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"
+
>
+
<svg
+
class="w-4 h-4"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
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"
+
/>
+
</svg>
+
<span>Request New Code</span>
+
</button>
+
</div>
+
)}
+
</div>
+
)}
+
</div>
+
</div>
+
)}
+
</div>
+
</div>
+
))}
+
</div>
+
+
{/* Success Message */}
+
{steps[2].status === "completed" && (
+
<div class="p-4 bg-green-50 dark:bg-green-900/50 rounded-lg border-2 border-green-200 dark:border-green-800">
+
<div class="flex items-center space-x-3 mb-4">
+
<svg
+
class="w-6 h-6 text-green-500"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
+
/>
+
</svg>
+
<h4 class="text-lg font-medium text-green-800 dark:text-green-200">
+
PLC Update Successful!
+
</h4>
+
</div>
+
<p class="text-sm text-green-700 dark:text-green-300 mb-4">
+
Your rotation key has been successfully added to your PLC record.
+
You can now use this key for future DID modifications.
+
</p>
+
<div class="flex space-x-4">
+
<button
+
type="button"
+
onClick={async () => {
+
try {
+
const response = await fetch("/api/logout", {
+
method: "POST",
+
credentials: "include",
+
});
+
if (!response.ok) {
+
throw new Error("Logout failed");
+
}
+
globalThis.location.href = "/";
+
} catch (error) {
+
console.error("Failed to logout:", error);
+
}
+
}}
+
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"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
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"
+
/>
+
</svg>
+
<span>Sign Out</span>
+
</button>
+
<a
+
href="https://ko-fi.com/knotbin"
+
target="_blank"
+
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"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
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"
+
/>
+
</svg>
+
<span>Support Us</span>
+
</a>
+
</div>
+
</div>
+
)}
+
</div>
+
);
+
}
+5
islands/HandleInput.tsx
···
import { useState } from "preact/hooks";
import { JSX } from "preact";
+
/**
+
* The OAuth handle input form.
+
* @returns The handle input form
+
* @component
+
*/
export default function HandleInput() {
const [handle, setHandle] = useState("");
const [error, setError] = useState<string | null>(null);
+31 -7
islands/Header.tsx
···
import { IS_BROWSER } from "fresh/runtime";
import { Button } from "../components/Button.tsx";
+
/**
+
* The user interface.
+
* @type {User}
+
*/
interface User {
did: string;
handle?: string;
}
+
/**
+
* Truncate text to a maximum length.
+
* @param text - The text to truncate
+
* @param maxLength - The maximum length
+
* @returns The truncated text
+
*/
function truncateText(text: string, maxLength: number) {
if (text.length <= maxLength) return text;
let truncated = text.slice(0, maxLength);
···
return truncated + "...";
}
+
/**
+
* The header component.
+
* @returns The header component
+
* @component
+
*/
export default function Header() {
const [user, setUser] = useState<User | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
···
setUser(
userData
? {
-
did: userData.did,
-
handle: userData.handle,
-
}
-
: null,
+
did: userData.did,
+
handle: userData.handle,
+
}
+
: null
);
} catch (error) {
console.error("Failed to fetch user:", error);
···
};
return (
-
<header className="bg-white dark:bg-slate-900 text-slate-900 dark:text-white relative z-10 border-b border-slate-200 dark:border-slate-800">
+
<header className="hidden sm:block bg-white dark:bg-slate-900 text-slate-900 dark:text-white relative z-10 border-b border-slate-200 dark:border-slate-800">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-between py-4">
{/* Home Link */}
···
/>
<div className="flex items-center gap-3">
+
{/* Ticket booth (did:plc update) */}
+
<Button
+
href="/ticket-booth"
+
color="amber"
+
icon="/icons/ticket_bold.svg"
+
iconAlt="Ticket"
+
label="TICKET BOOTH"
+
/>
+
{/* Departures (Migration) */}
<Button
href="/migrate"
···
<div className="relative">
<Button
color="amber"
-
icon="/icons/ticket_bold.svg"
+
icon="/icons/account.svg"
iconAlt="Check-in"
label="CHECKED IN"
onClick={() => setShowDropdown(!showDropdown)}
···
<Button
href="/login"
color="amber"
-
icon="/icons/ticket_bold.svg"
+
icon="/icons/account.svg"
iconAlt="Check-in"
label="CHECK-IN"
/>
+35
islands/LoginButton.tsx
···
+
import { useEffect, useState } from "preact/hooks";
+
import { Button } from "../components/Button.tsx";
+
+
export default function LoginButton() {
+
const [isMobile, setIsMobile] = useState(true); // Default to mobile for SSR
+
+
useEffect(() => {
+
const checkMobile = () => {
+
setIsMobile(globalThis.innerWidth < 640);
+
};
+
+
// Check on mount
+
checkMobile();
+
+
// Listen for resize events
+
globalThis.addEventListener('resize', checkMobile);
+
return () => globalThis.removeEventListener('resize', checkMobile);
+
}, []);
+
+
return (
+
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
+
<Button
+
href={isMobile ? undefined : "/login"}
+
color="blue"
+
label={isMobile ? "MOBILE NOT SUPPORTED" : "GET STARTED"}
+
className={isMobile ? "opacity-50 cursor-not-allowed" : "opacity-100 cursor-pointer"}
+
onClick={(e: MouseEvent) => {
+
if (isMobile) {
+
e.preventDefault();
+
}
+
}}
+
/>
+
</div>
+
);
+
}
+5
islands/LoginSelector.tsx
···
import HandleInput from "./HandleInput.tsx"
import CredLogin from "./CredLogin.tsx"
+
/**
+
* The login method selector for OAuth or Credential.
+
* @returns The login method selector
+
* @component
+
*/
export default function LoginMethodSelector() {
const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password')
+568 -199
islands/MigrationProgress.tsx
···
import { useEffect, useState } from "preact/hooks";
+
/**
+
* The migration state info.
+
* @type {MigrationStateInfo}
+
*/
+
interface MigrationStateInfo {
+
state: "up" | "issue" | "maintenance";
+
message: string;
+
allowMigration: boolean;
+
}
+
+
/**
+
* The migration progress props.
+
* @type {MigrationProgressProps}
+
*/
interface MigrationProgressProps {
service: string;
handle: string;
···
invite?: string;
}
+
/**
+
* The migration step.
+
* @type {MigrationStep}
+
*/
interface MigrationStep {
name: string;
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
error?: string;
+
isVerificationError?: boolean;
}
+
/**
+
* The migration progress component.
+
* @param props - The migration progress props
+
* @returns The migration progress component
+
* @component
+
*/
export default function MigrationProgress(props: MigrationProgressProps) {
const [token, setToken] = useState("");
+
const [migrationState, setMigrationState] = useState<
+
MigrationStateInfo | null
+
>(null);
+
const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>(
+
{},
+
);
+
const [showContinueAnyway, setShowContinueAnyway] = useState<
+
Record<number, boolean>
+
>({});
const [steps, setSteps] = useState<MigrationStep[]>([
{ name: "Create Account", status: "pending" },
···
index: number,
status: MigrationStep["status"],
error?: string,
+
isVerificationError?: boolean,
) => {
console.log(
`Updating step ${index} to ${status}${
···
setSteps((prevSteps) =>
prevSteps.map((step, i) =>
i === index
-
? { ...step, status, error }
+
? { ...step, status, error, isVerificationError }
: i > index
-
? { ...step, status: "pending", error: undefined }
+
? {
+
...step,
+
status: "pending",
+
error: undefined,
+
isVerificationError: undefined,
+
}
: step
)
);
···
invite: props.invite,
});
-
if (!validateParams()) {
-
console.log("Parameter validation failed");
-
return;
-
}
+
// Check migration state first
+
const checkMigrationState = async () => {
+
try {
+
const migrationResponse = await fetch("/api/migration-state");
+
if (migrationResponse.ok) {
+
const migrationData = await migrationResponse.json();
+
setMigrationState(migrationData);
-
startMigration().catch((error) => {
-
console.error("Unhandled migration error:", error);
-
updateStepStatus(
-
0,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
});
+
if (!migrationData.allowMigration) {
+
updateStepStatus(0, "error", migrationData.message);
+
return;
+
}
+
}
+
} catch (error) {
+
console.error("Failed to check migration state:", error);
+
updateStepStatus(0, "error", "Unable to verify migration availability");
+
return;
+
}
+
+
if (!validateParams()) {
+
console.log("Parameter validation failed");
+
return;
+
}
+
+
startMigration().catch((error) => {
+
console.error("Unhandled migration error:", error);
+
updateStepStatus(
+
0,
+
"error",
+
error.message || "Unknown error occurred",
+
);
+
});
+
};
+
+
checkMigrationState();
}, []);
const getStepDisplayName = (step: MigrationStep, index: number) => {
if (step.status === "completed") {
switch (index) {
-
case 0: return "Account Created";
-
case 1: return "Data Migrated";
-
case 2: return "Identity Migrated";
-
case 3: return "Migration Finalized";
+
case 0:
+
return "Account Created";
+
case 1:
+
return "Data Migrated";
+
case 2:
+
return "Identity Migrated";
+
case 3:
+
return "Migration Finalized";
}
}
-
+
if (step.status === "in-progress") {
switch (index) {
-
case 0: return "Creating your new account...";
-
case 1: return "Migrating your data...";
-
case 2: return step.name === "Enter the token sent to your email to complete identity migration"
-
? step.name
-
: "Migrating your identity...";
-
case 3: return "Finalizing migration...";
+
case 0:
+
return "Creating your new account...";
+
case 1:
+
return "Migrating your data...";
+
case 2:
+
return step.name ===
+
"Enter the token sent to your email to complete identity migration"
+
? step.name
+
: "Migrating your identity...";
+
case 3:
+
return "Finalizing migration...";
}
}
if (step.status === "verifying") {
switch (index) {
-
case 0: return "Verifying account creation...";
-
case 1: return "Verifying data migration...";
-
case 2: return "Verifying identity migration...";
-
case 3: return "Verifying migration completion...";
+
case 0:
+
return "Verifying account creation...";
+
case 1:
+
return "Verifying data migration...";
+
case 2:
+
return "Verifying identity migration...";
+
case 3:
+
return "Verifying migration completion...";
}
}
-
+
return step.name;
};
···
updateStepStatus(0, "verifying");
const verified = await verifyStep(0);
if (!verified) {
-
throw new Error("Account creation verification failed");
-
}
-
} catch (error) {
-
updateStepStatus(
-
0,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
-
// Step 2: Migrate Data
-
updateStepStatus(1, "in-progress");
-
console.log("Starting data migration...");
-
-
try {
-
const dataRes = await fetch("/api/migrate/data", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Data migration response status:", dataRes.status);
-
const dataText = await dataRes.text();
-
console.log("Data migration response:", dataText);
-
-
if (!dataRes.ok) {
-
try {
-
const json = JSON.parse(dataText);
-
throw new Error(json.message || "Failed to migrate data");
-
} catch {
-
throw new Error(dataText || "Failed to migrate data");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(dataText);
-
if (!jsonData.success) {
-
throw new Error(jsonData.message || "Data migration failed");
-
}
-
console.log("Data migration successful:", jsonData);
-
} catch (e) {
-
console.error("Failed to parse data migration response:", e);
-
throw new Error("Invalid response from server during data migration");
-
}
-
-
updateStepStatus(1, "verifying");
-
const verified = await verifyStep(1);
-
if (!verified) {
-
throw new Error("Data migration verification failed");
-
}
-
} catch (error) {
-
updateStepStatus(
-
1,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
-
// Step 3: Request Identity Migration
-
updateStepStatus(2, "in-progress");
-
console.log("Requesting identity migration...");
-
-
try {
-
const requestRes = await fetch("/api/migrate/identity/request", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Identity request response status:", requestRes.status);
-
const requestText = await requestRes.text();
-
console.log("Identity request response:", requestText);
-
-
if (!requestRes.ok) {
-
try {
-
const json = JSON.parse(requestText);
-
throw new Error(json.message || "Failed to request identity migration");
-
} catch {
-
throw new Error(requestText || "Failed to request identity migration");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(requestText);
-
if (!jsonData.success) {
-
throw new Error(
-
jsonData.message || "Identity migration request failed",
-
);
-
}
-
console.log("Identity migration requested successfully");
-
-
// Update step name to prompt for token
-
setSteps(prevSteps =>
-
prevSteps.map((step, i) =>
-
i === 2
-
? { ...step, name: "Enter the token sent to your email to complete identity migration" }
-
: step
-
)
+
console.log(
+
"Account creation: Verification failed, waiting for user action",
);
-
// Don't continue with migration - wait for token input
return;
-
} catch (e) {
-
console.error("Failed to parse identity request response:", e);
-
throw new Error(
-
"Invalid response from server during identity request",
-
);
}
+
+
// If verification succeeds, continue to data migration
+
await startDataMigration();
} catch (error) {
updateStepStatus(
-
2,
+
0,
"error",
error instanceof Error ? error.message : String(error),
);
···
if (!identityRes.ok) {
try {
const json = JSON.parse(identityData);
-
throw new Error(json.message || "Failed to complete identity migration");
+
throw new Error(
+
json.message || "Failed to complete identity migration",
+
);
} catch {
-
throw new Error(identityData || "Failed to complete identity migration");
+
throw new Error(
+
identityData || "Failed to complete identity migration",
+
);
}
}
···
throw new Error("Invalid response from server");
}
-
updateStepStatus(2, "verifying");
const verified = await verifyStep(2);
if (!verified) {
-
throw new Error("Identity migration verification failed");
-
}
-
-
// Step 4: Finalize Migration
-
updateStepStatus(3, "in-progress");
-
try {
-
const finalizeRes = await fetch("/api/migrate/finalize", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
const finalizeData = await finalizeRes.text();
-
if (!finalizeRes.ok) {
-
try {
-
const json = JSON.parse(finalizeData);
-
throw new Error(json.message || "Failed to finalize migration");
-
} catch {
-
throw new Error(finalizeData || "Failed to finalize migration");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(finalizeData);
-
if (!jsonData.success) {
-
throw new Error(jsonData.message || "Finalization failed");
-
}
-
} catch {
-
throw new Error("Invalid response from server during finalization");
-
}
-
-
updateStepStatus(3, "verifying");
-
const verified = await verifyStep(3);
-
if (!verified) {
-
throw new Error("Migration finalization verification failed");
-
}
-
} catch (error) {
-
updateStepStatus(
-
3,
-
"error",
-
error instanceof Error ? error.message : String(error),
+
console.log(
+
"Identity migration: Verification failed, waiting for user action",
);
-
throw error;
+
return;
}
+
+
// If verification succeeds, continue to finalization
+
await startFinalization();
} catch (error) {
console.error("Identity migration error:", error);
updateStepStatus(
···
// Helper to verify a step after completion
const verifyStep = async (stepNum: number) => {
+
console.log(`Verification: Starting step ${stepNum + 1}`);
updateStepStatus(stepNum, "verifying");
try {
+
console.log(`Verification: Fetching status for step ${stepNum + 1}`);
const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`);
+
console.log(`Verification: Status response status:`, res.status);
const data = await res.json();
+
console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
+
if (data.ready) {
+
console.log(`Verification: Step ${stepNum + 1} is ready`);
updateStepStatus(stepNum, "completed");
+
// Reset retry state on success
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 }));
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
+
+
// Continue to next step if not the last one
+
if (stepNum < 3) {
+
setTimeout(() => continueToNextStep(stepNum + 1), 500);
+
}
+
return true;
} else {
-
updateStepStatus(stepNum, "error", data.reason || "Verification failed");
+
console.log(
+
`Verification: Step ${stepNum + 1} is not ready:`,
+
data.reason,
+
);
+
const statusDetails = {
+
activated: data.activated,
+
validDid: data.validDid,
+
repoCommit: data.repoCommit,
+
repoRev: data.repoRev,
+
repoBlocks: data.repoBlocks,
+
expectedRecords: data.expectedRecords,
+
indexedRecords: data.indexedRecords,
+
privateStateValues: data.privateStateValues,
+
expectedBlobs: data.expectedBlobs,
+
importedBlobs: data.importedBlobs,
+
};
+
console.log(
+
`Verification: Step ${stepNum + 1} status details:`,
+
statusDetails,
+
);
+
const errorMessage = `${
+
data.reason || "Verification failed"
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
+
+
// Track retry attempts
+
const currentAttempts = retryAttempts[stepNum] || 0;
+
setRetryAttempts((prev) => ({
+
...prev,
+
[stepNum]: currentAttempts + 1,
+
}));
+
+
// Show continue anyway option if this is the second failure
+
if (currentAttempts >= 1) {
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
+
}
+
+
updateStepStatus(stepNum, "error", errorMessage, true);
return false;
}
} catch (e) {
-
updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(e));
+
console.error(`Verification: Error in step ${stepNum + 1}:`, e);
+
const currentAttempts = retryAttempts[stepNum] || 0;
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 }));
+
+
// Show continue anyway option if this is the second failure
+
if (currentAttempts >= 1) {
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
+
}
+
+
updateStepStatus(
+
stepNum,
+
"error",
+
e instanceof Error ? e.message : String(e),
+
true,
+
);
return false;
}
};
+
const retryVerification = async (stepNum: number) => {
+
console.log(`Retrying verification for step ${stepNum + 1}`);
+
await verifyStep(stepNum);
+
};
+
+
const continueAnyway = (stepNum: number) => {
+
console.log(`Continuing anyway for step ${stepNum + 1}`);
+
updateStepStatus(stepNum, "completed");
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
+
+
// Continue with next step if not the last one
+
if (stepNum < 3) {
+
continueToNextStep(stepNum + 1);
+
}
+
};
+
+
const continueToNextStep = async (stepNum: number) => {
+
switch (stepNum) {
+
case 1:
+
// Continue to data migration
+
await startDataMigration();
+
break;
+
case 2:
+
// Continue to identity migration
+
await startIdentityMigration();
+
break;
+
case 3:
+
// Continue to finalization
+
await startFinalization();
+
break;
+
}
+
};
+
+
const startDataMigration = async () => {
+
// Step 2: Migrate Data
+
updateStepStatus(1, "in-progress");
+
console.log("Starting data migration...");
+
+
try {
+
// Step 2.1: Migrate Repo
+
console.log("Data migration: Starting repo migration");
+
const repoRes = await fetch("/api/migrate/data/repo", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Repo migration: Response status:", repoRes.status);
+
const repoText = await repoRes.text();
+
console.log("Repo migration: Raw response:", repoText);
+
+
if (!repoRes.ok) {
+
try {
+
const json = JSON.parse(repoText);
+
console.error("Repo migration: Error response:", json);
+
throw new Error(json.message || "Failed to migrate repo");
+
} catch {
+
console.error("Repo migration: Non-JSON error response:", repoText);
+
throw new Error(repoText || "Failed to migrate repo");
+
}
+
}
+
+
// Step 2.2: Migrate Blobs
+
console.log("Data migration: Starting blob migration");
+
const blobsRes = await fetch("/api/migrate/data/blobs", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Blob migration: Response status:", blobsRes.status);
+
const blobsText = await blobsRes.text();
+
console.log("Blob migration: Raw response:", blobsText);
+
+
if (!blobsRes.ok) {
+
try {
+
const json = JSON.parse(blobsText);
+
console.error("Blob migration: Error response:", json);
+
throw new Error(json.message || "Failed to migrate blobs");
+
} catch {
+
console.error(
+
"Blob migration: Non-JSON error response:",
+
blobsText,
+
);
+
throw new Error(blobsText || "Failed to migrate blobs");
+
}
+
}
+
+
// Step 2.3: Migrate Preferences
+
console.log("Data migration: Starting preferences migration");
+
const prefsRes = await fetch("/api/migrate/data/prefs", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Preferences migration: Response status:", prefsRes.status);
+
const prefsText = await prefsRes.text();
+
console.log("Preferences migration: Raw response:", prefsText);
+
+
if (!prefsRes.ok) {
+
try {
+
const json = JSON.parse(prefsText);
+
console.error("Preferences migration: Error response:", json);
+
throw new Error(json.message || "Failed to migrate preferences");
+
} catch {
+
console.error(
+
"Preferences migration: Non-JSON error response:",
+
prefsText,
+
);
+
throw new Error(prefsText || "Failed to migrate preferences");
+
}
+
}
+
+
console.log("Data migration: Starting verification");
+
updateStepStatus(1, "verifying");
+
const verified = await verifyStep(1);
+
console.log("Data migration: Verification result:", verified);
+
if (!verified) {
+
console.log(
+
"Data migration: Verification failed, waiting for user action",
+
);
+
return;
+
}
+
+
// If verification succeeds, continue to next step
+
await startIdentityMigration();
+
} catch (error) {
+
console.error("Data migration: Error caught:", error);
+
updateStepStatus(
+
1,
+
"error",
+
error instanceof Error ? error.message : String(error),
+
);
+
throw error;
+
}
+
};
+
+
const startIdentityMigration = async () => {
+
// Step 3: Request Identity Migration
+
updateStepStatus(2, "in-progress");
+
console.log("Requesting identity migration...");
+
+
try {
+
const requestRes = await fetch("/api/migrate/identity/request", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
console.log("Identity request response status:", requestRes.status);
+
const requestText = await requestRes.text();
+
console.log("Identity request response:", requestText);
+
+
if (!requestRes.ok) {
+
try {
+
const json = JSON.parse(requestText);
+
throw new Error(
+
json.message || "Failed to request identity migration",
+
);
+
} catch {
+
throw new Error(
+
requestText || "Failed to request identity migration",
+
);
+
}
+
}
+
+
try {
+
const jsonData = JSON.parse(requestText);
+
if (!jsonData.success) {
+
throw new Error(
+
jsonData.message || "Identity migration request failed",
+
);
+
}
+
console.log("Identity migration requested successfully");
+
+
// Update step name to prompt for token
+
setSteps((prevSteps) =>
+
prevSteps.map((step, i) =>
+
i === 2
+
? {
+
...step,
+
name:
+
"Enter the token sent to your email to complete identity migration",
+
}
+
: step
+
)
+
);
+
// Don't continue with migration - wait for token input
+
return;
+
} catch (e) {
+
console.error("Failed to parse identity request response:", e);
+
throw new Error(
+
"Invalid response from server during identity request",
+
);
+
}
+
} catch (error) {
+
updateStepStatus(
+
2,
+
"error",
+
error instanceof Error ? error.message : String(error),
+
);
+
throw error;
+
}
+
};
+
+
const startFinalization = async () => {
+
// Step 4: Finalize Migration
+
updateStepStatus(3, "in-progress");
+
try {
+
const finalizeRes = await fetch("/api/migrate/finalize", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const finalizeData = await finalizeRes.text();
+
if (!finalizeRes.ok) {
+
try {
+
const json = JSON.parse(finalizeData);
+
throw new Error(json.message || "Failed to finalize migration");
+
} catch {
+
throw new Error(finalizeData || "Failed to finalize migration");
+
}
+
}
+
+
try {
+
const jsonData = JSON.parse(finalizeData);
+
if (!jsonData.success) {
+
throw new Error(jsonData.message || "Finalization failed");
+
}
+
} catch {
+
throw new Error("Invalid response from server during finalization");
+
}
+
+
updateStepStatus(3, "verifying");
+
const verified = await verifyStep(3);
+
if (!verified) {
+
console.log(
+
"Finalization: Verification failed, waiting for user action",
+
);
+
return;
+
}
+
} catch (error) {
+
updateStepStatus(
+
3,
+
"error",
+
error instanceof Error ? error.message : String(error),
+
);
+
throw error;
+
}
+
};
+
return (
<div class="space-y-8">
+
{/* Migration state alert */}
+
{migrationState && !migrationState.allowMigration && (
+
<div
+
class={`p-4 rounded-lg border ${
+
migrationState.state === "maintenance"
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
+
}`}
+
>
+
<div class="flex items-center">
+
<div
+
class={`mr-3 ${
+
migrationState.state === "maintenance"
+
? "text-yellow-600 dark:text-yellow-400"
+
: "text-red-600 dark:text-red-400"
+
}`}
+
>
+
{migrationState.state === "maintenance" ? "โš ๏ธ" : "๐Ÿšซ"}
+
</div>
+
<div>
+
<h3 class="font-semibold mb-1">
+
{migrationState.state === "maintenance"
+
? "Maintenance Mode"
+
: "Service Unavailable"}
+
</h3>
+
<p class="text-sm">{migrationState.message}</p>
+
</div>
+
</div>
+
</div>
+
)}
+
<div class="space-y-4">
{steps.map((step, index) => (
<div key={step.name} class={getStepClasses(step.status)}>
···
{getStepDisplayName(step, index)}
</p>
{step.error && (
-
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
-
{(() => {
-
try {
-
const err = JSON.parse(step.error);
-
return err.message || step.error;
-
} catch {
-
return step.error;
-
}
-
})()}
-
</p>
+
<div class="mt-1">
+
<p class="text-sm text-red-600 dark:text-red-400">
+
{(() => {
+
try {
+
const err = JSON.parse(step.error);
+
return err.message || step.error;
+
} catch {
+
return step.error;
+
}
+
})()}
+
</p>
+
{step.isVerificationError && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={() => retryVerification(index)}
+
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"
+
>
+
Retry Verification
+
</button>
+
{showContinueAnyway[index] && (
+
<button
+
type="button"
+
onClick={() => continueAnyway(index)}
+
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
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
+
>
+
Continue Anyway
+
</button>
+
)}
+
</div>
+
)}
+
</div>
)}
{index === 2 && step.status === "in-progress" &&
-
step.name === "Enter the token sent to your email to complete identity migration" && (
+
step.name ===
+
"Enter the token sent to your email to complete identity migration" &&
+
(
<div class="mt-4 space-y-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
-
Please check your email for the migration token and enter it below:
+
Please check your email for the migration token and enter
+
it below:
</p>
<div class="flex space-x-2">
<input
···
</button>
</div>
</div>
-
)
-
}
+
)}
</div>
</div>
))}
</div>
-
-
{steps[3].status === "completed" && (
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
-
<p class="text-sm text-green-800 dark:text-green-200">
-
Migration completed successfully! You can now close this page.
+
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
+
Migration completed successfully! Sign out to finish the process and
+
return home.<br />
+
Please consider donating to Airport to support server and
+
development costs.
</p>
+
<div class="flex space-x-4">
+
<button
+
type="button"
+
onClick={async () => {
+
try {
+
const response = await fetch("/api/logout", {
+
method: "POST",
+
credentials: "include",
+
});
+
if (!response.ok) {
+
throw new Error("Logout failed");
+
}
+
globalThis.location.href = "/";
+
} catch (error) {
+
console.error("Failed to logout:", error);
+
}
+
}}
+
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"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
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"
+
/>
+
</svg>
+
<span>Sign Out</span>
+
</button>
+
<a
+
href="https://ko-fi.com/knotbin"
+
target="_blank"
+
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"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
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"
+
/>
+
</svg>
+
<span>Support Us</span>
+
</a>
+
</div>
</div>
)}
</div>
+173 -8
islands/MigrationSetup.tsx
···
-
import { useState } from "preact/hooks";
+
import { useState, useEffect } from "preact/hooks";
+
import { IS_BROWSER } from "fresh/runtime";
+
/**
+
* The migration setup props.
+
* @type {MigrationSetupProps}
+
*/
interface MigrationSetupProps {
service?: string | null;
handle?: string | null;
···
invite?: string | null;
}
+
/**
+
* The server description.
+
* @type {ServerDescription}
+
*/
interface ServerDescription {
inviteCodeRequired: boolean;
availableUserDomains: string[];
}
+
/**
+
* The user passport.
+
* @type {UserPassport}
+
*/
+
interface UserPassport {
+
did: string;
+
handle: string;
+
pds: string;
+
createdAt?: string;
+
}
+
+
/**
+
* The migration state info.
+
* @type {MigrationStateInfo}
+
*/
+
interface MigrationStateInfo {
+
state: "up" | "issue" | "maintenance";
+
message: string;
+
allowMigration: boolean;
+
}
+
+
/**
+
* The migration setup component.
+
* @param props - The migration setup props
+
* @returns The migration setup component
+
* @component
+
*/
export default function MigrationSetup(props: MigrationSetupProps) {
const [service, setService] = useState(props.service || "");
const [handlePrefix, setHandlePrefix] = useState(
···
const [isLoading, setIsLoading] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationText, setConfirmationText] = useState("");
+
const [passport, setPassport] = useState<UserPassport | null>(null);
+
const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null);
+
+
const ensureServiceUrl = (url: string): string => {
+
if (!url) return url;
+
try {
+
// If it already has a protocol, return as is
+
new URL(url);
+
return url;
+
} catch {
+
// If no protocol, add https://
+
return `https://${url}`;
+
}
+
};
+
+
useEffect(() => {
+
if (!IS_BROWSER) return;
+
+
const fetchInitialData = async () => {
+
try {
+
// Check migration state first
+
const migrationResponse = await fetch("/api/migration-state");
+
if (migrationResponse.ok) {
+
const migrationData = await migrationResponse.json();
+
setMigrationState(migrationData);
+
}
+
+
// Fetch user passport
+
const response = await fetch("/api/me", {
+
credentials: "include",
+
});
+
if (!response.ok) {
+
throw new Error("Failed to fetch user profile");
+
}
+
const userData = await response.json();
+
if (userData) {
+
// Get PDS URL from the current service
+
const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`);
+
const pdsData = await pdsResponse.json();
+
+
setPassport({
+
did: userData.did,
+
handle: userData.handle,
+
pds: pdsData.pds || "Unknown",
+
createdAt: new Date().toISOString() // TODO: Get actual creation date from API
+
});
+
}
+
} catch (error) {
+
console.error("Failed to fetch initial data:", error);
+
}
+
};
+
+
fetchInitialData();
+
}, []);
const checkServerDescription = async (serviceUrl: string) => {
try {
···
};
const handleServiceChange = (value: string) => {
-
setService(value);
+
const urlWithProtocol = ensureServiceUrl(value);
+
setService(urlWithProtocol);
setError("");
-
if (value) {
-
checkServerDescription(value);
+
if (urlWithProtocol) {
+
checkServerDescription(urlWithProtocol);
} else {
setAvailableDomains([]);
setSelectedDomain("");
···
const handleSubmit = (e: Event) => {
e.preventDefault();
+
// Check migration state first
+
if (migrationState && !migrationState.allowMigration) {
+
setError(migrationState.message);
+
return;
+
}
+
if (!service || !handlePrefix || !email || !password) {
setError("Please fill in all required fields");
return;
···
};
const handleConfirmation = () => {
+
// Double-check migration state before proceeding
+
if (migrationState && !migrationState.allowMigration) {
+
setError(migrationState.message);
+
return;
+
}
+
if (confirmationText !== "MIGRATE") {
setError("Please type 'MIGRATE' to confirm");
return;
···
<div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div>
<div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div>
+
{/* Migration state alert */}
+
{migrationState && !migrationState.allowMigration && (
+
<div class={`mb-6 mt-4 p-4 rounded-lg border ${
+
migrationState.state === "maintenance"
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
+
}`}>
+
<div class="flex items-center">
+
<div class={`mr-3 ${
+
migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400"
+
}`}>
+
{migrationState.state === "maintenance" ? "โš ๏ธ" : "๐Ÿšซ"}
+
</div>
+
<div>
+
<h3 class="font-semibold mb-1">
+
{migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"}
+
</h3>
+
<p class="text-sm">{migrationState.message}</p>
+
</div>
+
</div>
+
</div>
+
)}
+
<div class="text-center mb-8 relative">
<p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div>
</div>
+
{/* Passport Section */}
+
{passport && (
+
<div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
+
<div class="flex items-center justify-between mb-4">
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Current Passport</h3>
+
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">ISSUED: {new Date().toLocaleDateString()}</div>
+
</div>
+
<div class="grid grid-cols-2 gap-4 text-sm">
+
<div>
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div>
+
<div class="font-mono text-gray-900 dark:text-white">{passport.handle}</div>
+
</div>
+
<div>
+
<div class="text-gray-500 dark:text-gray-400 mb-1">DID</div>
+
<div class="font-mono text-gray-900 dark:text-white break-all">{passport.did}</div>
+
</div>
+
<div>
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Citizen of PDS</div>
+
<div class="font-mono text-gray-900 dark:text-white break-all">{passport.pds}</div>
+
</div>
+
<div>
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Account Age</div>
+
<div class="font-mono text-gray-900 dark:text-white">
+
{passport.createdAt ? new Date(passport.createdAt).toLocaleDateString() : "Unknown"}
+
</div>
+
</div>
+
</div>
+
</div>
+
)}
+
<form onSubmit={handleSubmit} class="space-y-6">
{error && (
-
<div class="bg-red-50 dark:bg-red-900 rounded-lg">
+
<div class="bg-red-50 dark:bg-red-900 rounded-lg ">
<p class="text-red-800 dark:text-red-200 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
···
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
New Account Handle
<span class="text-xs text-gray-500 ml-1">(Passport ID)</span>
+
<div class="inline-block relative group ml-2">
+
<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">
+
<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" />
+
</svg>
+
<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">
+
You can change your handle to a custom domain later
+
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
+
</div>
+
</div>
</label>
<div class="mt-1 relative w-full">
<div class="flex rounded-md shadow-sm w-full">
···
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
-
Contact Email
+
Email
<span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span>
</label>
<div class="relative">
···
<button
type="submit"
-
disabled={isLoading}
+
disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)}
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"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
···
<div class="text-center mb-4 mt-6">
<h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3>
<p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
-
<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.
+
<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.
</p>
<p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
+10
islands/OAuthCallback.tsx
···
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
/**
+
* The OAuth callback props.
+
* @type {OAuthCallbackProps}
+
*/
interface OAuthCallbackProps {
error?: string;
}
+
/**
+
* The OAuth callback component.
+
* @param props - The OAuth callback props
+
* @returns The OAuth callback component
+
* @component
+
*/
export default function OAuthCallback(
{ error: initialError }: OAuthCallbackProps,
) {
-114
islands/PostHogAnalytics.tsx
···
-
import { useEffect } from "preact/hooks";
-
-
interface PostHogConfig {
-
api_host: string;
-
[key: string]: unknown; // Allow additional optional properties
-
}
-
-
interface PostHogInstance {
-
__SV?: number;
-
_i: Array<[string, PostHogConfig, string?]>;
-
people?: PostHogPeople;
-
init: (apiKey: string, config: PostHogConfig, name?: string) => void;
-
capture: (event: string, properties?: Record<string, unknown>) => void;
-
identify: (distinctId: string, properties?: Record<string, unknown>) => void;
-
toString: (includeStub?: number) => string;
-
[key: string]: unknown;
-
}
-
-
interface PostHogPeople {
-
toString: () => string;
-
[key: string]: unknown;
-
}
-
-
declare global {
-
var posthog: PostHogInstance | undefined;
-
}
-
-
interface PostHogProps {
-
apiKey: string;
-
apiHost?: string;
-
}
-
-
export default function PostHogAnalytics({
-
apiKey,
-
apiHost = "https://us.i.posthog.com"
-
}: PostHogProps) {
-
useEffect(() => {
-
// PostHog initialization script
-
(function(t: Document, e: PostHogInstance) {
-
let o: string[];
-
let n: number;
-
let p: HTMLScriptElement;
-
let r: HTMLScriptElement | null;
-
-
if (e.__SV) return;
-
-
globalThis.posthog = e;
-
e._i = [];
-
e.init = function(i: string, s: PostHogConfig, a?: string) {
-
function g(target: Record<string, unknown>, methodName: string) {
-
const parts = methodName.split(".");
-
if (2 == parts.length) {
-
target = target[parts[0]] as Record<string, unknown>;
-
methodName = parts[1];
-
}
-
target[methodName] = function() {
-
(target as { push: (args: unknown[]) => void }).push([methodName].concat(Array.prototype.slice.call(arguments, 0)));
-
};
-
}
-
-
p = t.createElement("script");
-
p.type = "text/javascript";
-
p.crossOrigin = "anonymous";
-
p.async = true;
-
p.src = s.api_host.replace(".i.posthog.com", "-assets.i.posthog.com") + "/static/array.js";
-
r = t.getElementsByTagName("script")[0];
-
if (r && r.parentNode) {
-
r.parentNode.insertBefore(p, r);
-
}
-
-
let u: PostHogInstance = e;
-
if (typeof a !== "undefined") {
-
u = (e as Record<string, PostHogInstance>)[a] = {} as PostHogInstance;
-
u._i = [];
-
} else {
-
a = "posthog";
-
}
-
-
u.people = u.people || {} as PostHogPeople;
-
u.toString = function(includeStub?: number) {
-
let name = "posthog";
-
if ("posthog" !== a) {
-
name += "." + a;
-
}
-
if (!includeStub) {
-
name += " (stub)";
-
}
-
return name;
-
};
-
-
u.people.toString = function() {
-
return u.toString(1) + ".people (stub)";
-
};
-
-
o = "init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" ");
-
-
for (n = 0; n < o.length; n++) {
-
g(u as Record<string, unknown>, o[n]);
-
}
-
-
e._i.push([i, s, a]);
-
};
-
-
e.__SV = 1;
-
})(document, globalThis.posthog || {} as PostHogInstance);
-
-
// Initialize PostHog
-
if (globalThis.posthog) {
-
globalThis.posthog.init(apiKey, { api_host: apiHost });
-
}
-
}, [apiKey, apiHost]);
-
-
return null; // This component doesn't render anything visible
-
}
-15
islands/PostHogInitializer.tsx
···
-
import { useEffect } from "preact/hooks";
-
import posthog from "posthog-js";
-
-
interface Props {
-
apiKey: string;
-
apiHost: string;
-
}
-
-
export default function PostHogInitializer({ apiKey, apiHost }: Props) {
-
useEffect(() => {
-
posthog.default.init(apiKey, { api_host: apiHost });
-
}, [apiKey, apiHost]);
-
-
return null;
-
}
+9
islands/SocialLinks.tsx
···
import { useEffect, useState } from "preact/hooks";
import * as Icon from 'npm:preact-feather';
+
/**
+
* The GitHub repository.
+
* @type {GitHubRepo}
+
*/
interface GitHubRepo {
stargazers_count: number;
}
+
/**
+
* The social links component.
+
* @returns The social links component
+
* @component
+
*/
export default function SocialLinks() {
const [starCount, setStarCount] = useState<number | null>(null);
+24 -7
islands/Ticket.tsx
···
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
import { Link } from "../components/Link.tsx";
+
/**
+
* The user interface for the ticket component.
+
* @type {User}
+
*/
interface User {
did: string;
handle?: string;
}
+
/**
+
* The ticket component for the landing page.
+
* @returns The ticket component
+
* @component
+
*/
export default function Ticket() {
const [user, setUser] = useState<User | null>(null);
···
setUser(
userData
? {
-
did: userData.did,
-
handle: userData.handle,
-
}
-
: null,
+
did: userData.did,
+
handle: userData.handle,
+
}
+
: null
);
} catch (error) {
console.error("Failed to fetch user:", error);
···
</p>
<p>
Think you might need to migrate in the future but your PDS might be
-
hostile or offline? No worries! Soon you'll be able to go to the
-
ticket booth and get a PLC key to use for account recovery in the
-
future. You can also go to baggage claim (take the air shuttle to
+
hostile or offline? No worries! You can go to the{" "}
+
<Link
+
href="/ticket-booth"
+
isExternal
+
class="text-blue-600 dark:text-blue-400"
+
>
+
ticket booth
+
</Link>{" "}
+
and get a PLC key to use for account recovery in the future. Soon
+
you'll also be able to go to baggage claim (take the air shuttle to
terminal four) and get a downloadable backup of all your current PDS
data in case that were to happen.
</p>
+7
lib/check-dids.ts
···
+
import { getSession } from "./sessions.ts";
+
+
export async function checkDidsMatch(req: Request): Promise<boolean> {
+
const oldSession = await getSession(req, undefined, false);
+
const newSession = await getSession(req, undefined, true);
+
return oldSession.did === newSession.did;
+
}
+55 -29
lib/cred/sessions.ts
···
let migrationSessionOptions: SessionOptions;
let credentialSessionOptions: SessionOptions;
+
/**
+
* Get the session options for the given request.
+
* @param isMigration - Whether to get the migration session options
+
* @returns The session options
+
*/
async function getOptions(isMigration: boolean) {
if (isMigration) {
if (!migrationSessionOptions) {
···
}
return migrationSessionOptions;
}
-
+
if (!credentialSessionOptions) {
credentialSessionOptions = await createSessionOptions("cred_sid");
}
return credentialSessionOptions;
}
+
/**
+
* Get the credential session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The credential session
+
*/
export async function getCredentialSession(
req: Request,
res: Response = new Response(),
isMigration: boolean = false
) {
const options = await getOptions(isMigration);
-
return getIronSession<CredentialSession>(
-
req,
-
res,
-
options,
-
);
+
return getIronSession<CredentialSession>(req, res, options);
}
+
/**
+
* Get the credential agent for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The credential agent
+
*/
export async function getCredentialAgent(
req: Request,
res: Response = new Response(),
-
isMigration: boolean = false,
+
isMigration: boolean = false
) {
-
const session = await getCredentialSession(
-
req,
-
res,
-
isMigration
-
);
-
if (!session.did || !session.service || !session.handle || !session.password) {
+
const session = await getCredentialSession(req, res, isMigration);
+
if (
+
!session.did ||
+
!session.service ||
+
!session.handle ||
+
!session.password
+
) {
return null;
}
···
}
}
+
/**
+
* Set the credential session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param data - The credential session data
+
* @param isMigration - Whether to set the migration session
+
* @returns The credential session
+
*/
export async function setCredentialSession(
req: Request,
res: Response,
data: CredentialSession,
-
isMigration: boolean = false,
+
isMigration: boolean = false
) {
-
const session = await getCredentialSession(
-
req,
-
res,
-
isMigration
-
);
+
const session = await getCredentialSession(req, res, isMigration);
session.did = data.did;
session.handle = data.handle;
session.service = data.service;
···
return session;
}
+
/**
+
* Get the credential session agent for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The credential session agent
+
*/
export async function getCredentialSessionAgent(
req: Request,
res: Response = new Response(),
-
isMigration: boolean = false,
+
isMigration: boolean = false
) {
-
const session = await getCredentialSession(
-
req,
-
res,
-
isMigration
-
);
+
const session = await getCredentialSession(req, res, isMigration);
console.log("Session state:", {
hasDid: !!session.did,
···
hasPassword: !!session.password,
hasAccessJwt: !!session.accessJwt,
service: session.service,
-
handle: session.handle
+
handle: session.handle,
});
if (
-
!session.did || !session.service || !session.handle || !session.password
+
!session.did ||
+
!session.service ||
+
!session.handle ||
+
!session.password
) {
console.log("Missing required session fields");
return null;
···
const sessionInfo = await agent.com.atproto.server.getSession();
console.log("Stored JWT is valid, session info:", {
did: sessionInfo.data.did,
-
handle: sessionInfo.data.handle
+
handle: sessionInfo.data.handle,
});
return agent;
} catch (err) {
···
console.log("Session created successfully:", {
did: sessionRes.data.did,
handle: sessionRes.data.handle,
-
hasAccessJwt: !!sessionRes.data.accessJwt
+
hasAccessJwt: !!sessionRes.data.accessJwt,
});
// Store the new token
+34 -1
lib/id-resolver.ts
···
pds: string;
}
+
interface DidService {
+
id: string;
+
type: string;
+
serviceEndpoint: string;
+
}
+
+
/**
+
* ID resolver instance.
+
*/
const idResolver = createIdResolver();
export const resolver = createBidirectionalResolver(idResolver);
+
/**
+
* Create the ID resolver.
+
* @returns The ID resolver
+
*/
export function createIdResolver() {
return new IdResolver();
}
+
/**
+
* The bidirectional resolver.
+
* @interface
+
*/
export interface BidirectionalResolver {
resolveDidToHandle(did: string): Promise<string>;
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>;
resolveDidToPdsUrl(did: string): Promise<string | undefined>;
}
+
/**
+
* Create the bidirectional resolver.
+
* @param resolver - The ID resolver
+
* @returns The bidirectional resolver
+
*/
export function createBidirectionalResolver(resolver: IdResolver) {
return {
async resolveDidToHandle(did: string): Promise<string> {
···
async resolveDidToPdsUrl(did: string): Promise<string | undefined> {
try {
+
// First try the standard resolution
const didDoc = await resolver.did.resolveAtprotoData(
did,
) as AtprotoData;
-
return didDoc.pds;
+
if (didDoc.pds) {
+
return didDoc.pds;
+
} else {
+
const forcedDidDoc = await resolver.did.resolveAtprotoData(
+
did,
+
true,
+
)
+
if (forcedDidDoc.pds) {
+
return forcedDidDoc.pds;
+
}
+
}
} catch (err) {
console.error("Error resolving PDS URL:", err);
return undefined;
+70
lib/migration-state.ts
···
+
/**
+
* Migration state types and utilities for controlling migration availability.
+
*/
+
+
export type MigrationState = "up" | "issue" | "maintenance";
+
+
export interface MigrationStateInfo {
+
state: MigrationState;
+
message: string;
+
allowMigration: boolean;
+
}
+
+
/**
+
* Get the current migration state from environment variables.
+
* @returns The migration state information
+
*/
+
export function getMigrationState(): MigrationStateInfo {
+
const state = (Deno.env.get("MIGRATION_STATE") || "up").toLowerCase() as MigrationState;
+
+
switch (state) {
+
case "issue":
+
return {
+
state: "issue",
+
message: "Migration services are temporarily unavailable as we investigate an issue. Please try again later.",
+
allowMigration: false,
+
};
+
+
case "maintenance":
+
return {
+
state: "maintenance",
+
message: "Migration services are temporarily unavailable for maintenance. Please try again later.",
+
allowMigration: false,
+
};
+
+
case "up":
+
default:
+
return {
+
state: "up",
+
message: "Migration services are operational.",
+
allowMigration: true,
+
};
+
}
+
}
+
+
/**
+
* Check if migrations are currently allowed.
+
* @returns True if migrations are allowed, false otherwise
+
*/
+
export function isMigrationAllowed(): boolean {
+
return getMigrationState().allowMigration;
+
}
+
+
/**
+
* Get a user-friendly message for the current migration state.
+
* @returns The message to display to users
+
*/
+
export function getMigrationStateMessage(): string {
+
return getMigrationState().message;
+
}
+
+
/**
+
* Throw an error if migrations are not allowed.
+
* Used in API endpoints to prevent migration operations when disabled.
+
*/
+
export function assertMigrationAllowed(): void {
+
const stateInfo = getMigrationState();
+
if (!stateInfo.allowMigration) {
+
throw new Error(stateInfo.message);
+
}
+
}
+5
lib/oauth/client.ts
···
import { AtprotoOAuthClient } from "@bigmoves/atproto-oauth-client";
import { SessionStore, StateStore } from "../storage.ts";
+
/**
+
* Create the OAuth client.
+
* @param db - The Deno KV instance for the database
+
* @returns The OAuth client
+
*/
export const createClient = (db: Deno.Kv) => {
if (Deno.env.get("NODE_ENV") == "production" && !Deno.env.get("PUBLIC_URL")) {
throw new Error("PUBLIC_URL is not set");
+15
lib/oauth/sessions.ts
···
let oauthSessionOptions: SessionOptions;
+
/**
+
* Get the OAuth session options.
+
* @returns The OAuth session options
+
*/
async function getOptions() {
if (!oauthSessionOptions) {
oauthSessionOptions = await createSessionOptions("oauth_sid");
···
return oauthSessionOptions;
}
+
/**
+
* Get the OAuth session agent for the given request.
+
* @param req - The request object
+
* @returns The OAuth session agent
+
*/
export async function getOauthSessionAgent(
req: Request
) {
···
}
}
+
/**
+
* Get the OAuth session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @returns The OAuth session
+
*/
export async function getOauthSession(
req: Request,
res: Response = new Response(),
+49 -10
lib/sessions.ts
···
import { Agent } from "npm:@atproto/api";
-
import { OauthSession, CredentialSession } from "./types.ts";
-
import { getCredentialSession, getCredentialSessionAgent } from "./cred/sessions.ts";
+
import { CredentialSession, OauthSession } from "./types.ts";
+
import {
+
getCredentialSession,
+
getCredentialSessionAgent,
+
} from "./cred/sessions.ts";
import { getOauthSession, getOauthSessionAgent } from "./oauth/sessions.ts";
import { IronSession } from "npm:iron-session";
+
/**
+
* Get the session for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The session
+
*/
export async function getSession(
req: Request,
res: Response = new Response(),
-
isMigration: boolean = false
+
isMigration: boolean = false,
): Promise<IronSession<OauthSession | CredentialSession>> {
if (isMigration) {
return await getCredentialSession(req, res, true);
···
const credentialSession = await getCredentialSession(req, res);
if (oauthSession.did) {
-
console.log("Oauth session found")
+
console.log("Oauth session found");
return oauthSession;
}
if (credentialSession.did) {
···
throw new Error("No session found");
}
+
/**
+
* Get the session agent for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
* @param isMigration - Whether to get the migration session
+
* @returns The session agent
+
*/
export async function getSessionAgent(
req: Request,
res: Response = new Response(),
-
isMigration: boolean = false
+
isMigration: boolean = false,
): Promise<Agent | null> {
if (isMigration) {
return await getCredentialSessionAgent(req, res, isMigration);
}
const oauthAgent = await getOauthSessionAgent(req);
-
const credentialAgent = await getCredentialSessionAgent(req, res, isMigration);
+
const credentialAgent = await getCredentialSessionAgent(
+
req,
+
res,
+
isMigration,
+
);
if (oauthAgent) {
return oauthAgent;
···
return null;
}
-
export async function destroyAllSessions(req: Request) {
-
const oauthSession = await getOauthSession(req);
-
const credentialSession = await getCredentialSession(req);
-
const migrationSession = await getCredentialSession(req, new Response(), true);
+
/**
+
* Destroy all sessions for the given request.
+
* @param req - The request object
+
* @param res - The response object
+
*/
+
export async function destroyAllSessions(
+
req: Request,
+
res?: Response,
+
): Promise<Response> {
+
const response = res || new Response();
+
const oauthSession = await getOauthSession(req, response);
+
const credentialSession = await getCredentialSession(req, res);
+
const migrationSession = await getCredentialSession(
+
req,
+
res,
+
true,
+
);
if (oauthSession.did) {
oauthSession.destroy();
···
credentialSession.destroy();
}
if (migrationSession.did) {
+
console.log("DESTROYING MIGRATION SESSION", migrationSession);
migrationSession.destroy();
+
} else {
+
console.log("MIGRATION SESSION NOT FOUND", migrationSession);
}
+
+
return response;
}
+9 -1
lib/storage.ts
···
NodeSavedSessionStore,
NodeSavedState,
NodeSavedStateStore,
-
} from "jsr:@bigmoves/atproto-oauth-client";
+
} from "@bigmoves/atproto-oauth-client";
+
/**
+
* The state store for sessions.
+
* @implements {NodeSavedStateStore}
+
*/
export class StateStore implements NodeSavedStateStore {
constructor(private db: Deno.Kv) {}
async get(key: string): Promise<NodeSavedState | undefined> {
···
}
}
+
/**
+
* The session store for sessions.
+
* @implements {NodeSavedSessionStore}
+
*/
export class SessionStore implements NodeSavedSessionStore {
constructor(private db: Deno.Kv) {}
async get(key: string): Promise<NodeSavedSession | undefined> {
+24 -1
lib/types.ts
···
import { SessionOptions as BaseSessionOptions } from "npm:iron-session";
+
/**
+
* The session options.
+
* @type {SessionOptions}
+
* @implements {BaseSessionOptions}
+
*/
interface SessionOptions extends BaseSessionOptions {
lockFn?: (key: string) => Promise<() => Promise<void>>;
}
-
// Helper function to create a lock using Deno KV
+
/**
+
* Create a lock using Deno KV.
+
* @param key - The key to lock
+
* @param db - The Deno KV instance for the database
+
* @returns The unlock function
+
*/
async function createLock(key: string, db: Deno.Kv): Promise<() => Promise<void>> {
const lockKey = ["session_lock", key];
const lockValue = Date.now();
···
};
}
+
/**
+
* The OAuth session.
+
* @type {OauthSession}
+
*/
export interface OauthSession {
did: string
}
+
/**
+
* The credential session.
+
* @type {CredentialSession}
+
*/
export interface CredentialSession {
did: string;
handle: string;
···
let db: Deno.Kv;
+
/**
+
* Create the session options.
+
* @param cookieName - The name of the iron session cookie
+
* @returns The session options for iron session
+
*/
export const createSessionOptions = async (cookieName: string): Promise<SessionOptions> => {
const cookieSecret = Deno.env.get("COOKIE_SECRET");
if (!cookieSecret) {
+4 -2
main.ts
···
const needsAuth = url.pathname.startsWith("/migrate");
// Skip auth check if not a protected route
-
if (!needsAuth || url.pathname === "/login" || url.pathname.startsWith("/api/")) {
+
if (
+
!needsAuth || url.pathname === "/login" || url.pathname.startsWith("/api/")
+
) {
return ctx.next();
}
try {
-
const session = await getSession(ctx.req)
+
const session = await getSession(ctx.req);
console.log("[auth] Session:", session);
+11 -8
routes/_app.tsx
···
import { type PageProps } from "fresh";
import Header from "../islands/Header.tsx";
-
import PostHogInitializer from "../islands/PostHogInitializer.tsx";
export default function App({ Component }: PageProps) {
-
const apiKey = Deno.env.get("PUBLIC_POSTHOG_KEY")!;
-
const apiHost = Deno.env.get("PUBLIC_POSTHOG_HOST")!;
-
return (
<html>
<head>
···
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="og:image" content="/og-image.jpg" />
<meta name="og:title" content="Airport" />
-
<meta name="og:description" content="
+
<meta
+
name="og:description"
+
content="
Airport is an AT Protocol PDS Migration Tool that allows you to seamlessly
migrate your account from one PDS to another.
-
" />
+
"
+
/>
<meta name="og:locale" content="en_US" />
<title>Airport</title>
<link rel="stylesheet" href="/styles.css" />
</head>
-
<script defer src="https://cloud.umami.is/script.js" data-website-id={Deno.env.get("UMAMI_ID")}></script>
+
<script
+
defer
+
src="https://cloud.umami.is/script.js"
+
data-website-id={Deno.env.get("UMAMI_ID")}
+
>
+
</script>
<body>
-
<PostHogInitializer apiKey={apiKey} apiHost={apiHost} />
<Header />
<main className="pt-8">
<Component />
+104
routes/about.tsx
···
+
import { Button } from "../components/Button.tsx";
+
+
export default function About() {
+
return (
+
<>
+
<div class="px-2 sm:px-4 py-4 sm:py-8 mx-auto">
+
<div class="max-w-screen-lg mx-auto flex flex-col items-center justify-center">
+
<div class="prose dark:prose-invert max-w-none w-full mb-0">
+
<h1 class="text-3xl font-bold text-center mb-8">About AT Protocol</h1>
+
+
<div class="space-y-6">
+
<section>
+
<h2 class="text-2xl font-semibold mb-4">What is AT Protocol?</h2>
+
<p class="text-gray-600 dark:text-gray-300">
+
AT Protocol (Authenticated Transfer Protocol) is the
+
foundation of Bluesky and other social apps like
+
<a href="https://tangled.sh">Tangled</a>,
+
<a href="https://spark.com">Spark</a>, and more.
+
Unlike traditional social platforms that lock your
+
data and identity to a single service, AT Protocol
+
gives you complete control over your digital presence.
+
Think of it as an open standard for social networking,
+
similar to how email works across different providers.
+
</p>
+
</section>
+
+
<section>
+
<h2 class="text-2xl font-semibold mb-4">Key Features</h2>
+
<ul class="list-disc pl-6 space-y-4 text-gray-600 dark:text-gray-300">
+
<li>
+
<strong>PDS Servers:</strong> PDS servers are where your data is stored.
+
They can be run by anyone, and they are very lightweight, allowing you to
+
choose which one to use or run your own. PDS servers just store your data,
+
meaning you don't have to switch PDS servers to use a different app or service.
+
You can have one PDS while using many different apps and services with the
+
same account.
+
</li>
+
<li>
+
<strong>Decentralized Identity:</strong> Your account is tied to a DID
+
(Decentralized Identifier) rather than your handle/username.
+
This means you can move your entire account, including your followers
+
and content, to any PDS by changing where your DID points.
+
It's also the reason you can use any domain as your handle, because
+
your identity is not tied to your handle. Your handle can change,
+
but your DID will always remain the same.
+
</li>
+
<li>
+
<strong>Portable Content:</strong> All your posts, likes, and other social
+
data are stored in your Personal Data Server (PDS).
+
You can switch PDS providers without losing any content or connections.
+
</li>
+
<li>
+
<strong>Architecture:</strong> The protocol uses a three-tier architecture:
+
Personal Data Servers (PDS) store your content,
+
relays broadcast a stream of all events on all PDSes,
+
and AppViews process and serve that stream into content for users.
+
This means when you make a post, the content is stored on your PDS,
+
picked up by relays, and AppViews listen to those relays to deliver
+
that post to all users.
+
</li>
+
<li>
+
<strong>Algorithmic Choice:</strong> You're not locked into a single algorithm
+
for your feed. Different services can offer different ways of curating content,
+
and you can choose which one you prefer. Bluesky offers a way to make custom
+
feeds, but even if it didn't, different apps could still offer their own
+
algorithms for curating content.
+
</li>
+
</ul>
+
</section>
+
+
<section>
+
<h2 class="text-2xl font-semibold mb-4">Learn More</h2>
+
<div class="space-y-4">
+
<p class="text-gray-600 dark:text-gray-300">
+
Want to dive deeper into AT Protocol? Check out these resources:
+
</p>
+
<ul class="list-none space-y-2">
+
<li>
+
<a href="https://atproto.com" class="text-blue-500 hover:underline">Official AT Protocol Docs</a> - The main source for protocol specs and information
+
</li>
+
<li>
+
<a href="https://github.com/bluesky-social/atproto" class="text-blue-500 hover:underline">GitHub Repository</a> - View the protocol implementation
+
</li>
+
<li>
+
<a href="https://atproto.wiki" class="text-blue-500 hover:underline">AT Protocol Wiki</a> - Community-driven documentation and resources
+
</li>
+
</ul>
+
</div>
+
</section>
+
</div>
+
+
<div class="mt-8 text-center">
+
<Button
+
href="/"
+
color="blue"
+
label="Back to Home"
+
/>
+
</div>
+
</div>
+
</div>
+
</div>
+
</>
+
);
+
}
+7
routes/api/cred/login.ts
···
import { define } from "../../../utils.ts";
import { Agent } from "npm:@atproto/api";
+
/**
+
* Handle credential login
+
* Save iron session to cookies
+
* Save credential session state to database
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the login result
+
*/
export const handler = define.handlers({
async POST(ctx) {
try {
+6 -3
routes/api/logout.ts
···
-
import { getSession } from "../../lib/sessions.ts";
+
import { destroyAllSessions, getSession } from "../../lib/sessions.ts";
import { oauthClient } from "../../lib/oauth/client.ts";
import { define } from "../../utils.ts";
···
if (session.did) {
// Try to revoke both types of sessions - the one that doesn't exist will just no-op
await Promise.all([
-
oauthClient.revoke(session.did).catch(console.error)
+
oauthClient.revoke(session.did).catch(console.error),
]);
// Then destroy the iron session
session.destroy();
}
-
return response;
+
// Destroy all sessions including migration session
+
const result = await destroyAllSessions(req, response);
+
+
return result;
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
console.error("Logout failed:", err.message);
+18 -2
routes/api/migrate/create.ts
···
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
import { Agent } from "@atproto/api";
import { define } from "../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../lib/migration-state.ts";
+
/**
+
* Handle account creation
+
* First step of the migration process
+
* Body must contain:
+
* - service: The service URL of the new account
+
* - handle: The handle of the new account
+
* - password: The password of the new account
+
* - email: The email of the new account
+
* - invite: The invite code of the new account (optional depending on the PDS)
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the creation result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
const body = await ctx.req.json();
const serviceUrl = body.service;
const newHandle = body.handle;
···
return new Response("Could not create new agent", { status: 400 });
}
-
console.log("getting did")
+
console.log("getting did");
const session = await oldAgent.com.atproto.server.getSession();
const accountDid = session.data.did;
-
console.log("got did")
+
console.log("got did");
const describeRes = await newAgent.com.atproto.server.describeServer();
const newServerDid = describeRes.data.did;
const inviteRequired = describeRes.data.inviteCodeRequired ?? false;
+359
routes/api/migrate/data/blobs.ts
···
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
+
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
+
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
+
console.log("Blob migration: Starting session retrieval");
+
const oldAgent = await getSessionAgent(ctx.req);
+
console.log("Blob migration: Got old agent:", !!oldAgent);
+
+
const newAgent = await getSessionAgent(ctx.req, res, true);
+
console.log("Blob migration: Got new agent:", !!newAgent);
+
+
if (!oldAgent) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Unauthorized",
+
}),
+
{
+
status: 401,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
if (!newAgent) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Migration session not found or invalid",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Migrate blobs
+
const migrationLogs: string[] = [];
+
const migratedBlobs: string[] = [];
+
const failedBlobs: string[] = [];
+
let pageCount = 0;
+
let blobCursor: string | undefined = undefined;
+
let totalBlobs = 0;
+
let processedBlobs = 0;
+
+
const startTime = Date.now();
+
console.log(`[${new Date().toISOString()}] Starting blob migration...`);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting blob migration...`,
+
);
+
+
// First count total blobs
+
console.log(`[${new Date().toISOString()}] Starting blob count...`);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting blob count...`,
+
);
+
+
const session = await oldAgent.com.atproto.server.getSession();
+
const accountDid = session.data.did;
+
+
do {
+
const pageStartTime = Date.now();
+
console.log(
+
`[${new Date().toISOString()}] Counting blobs on page ${
+
pageCount + 1
+
}...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Counting blobs on page ${
+
pageCount + 1
+
}...`,
+
);
+
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
+
did: accountDid,
+
cursor: blobCursor,
+
});
+
+
const newBlobs = listedBlobs.data.cids.length;
+
totalBlobs += newBlobs;
+
const pageTime = Date.now() - pageStartTime;
+
+
console.log(
+
`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${
+
pageCount + 1
+
} in ${pageTime / 1000} seconds, total so far: ${totalBlobs}`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${
+
pageCount + 1
+
} in ${pageTime / 1000} seconds, total so far: ${totalBlobs}`,
+
);
+
+
pageCount++;
+
blobCursor = listedBlobs.data.cursor;
+
} while (blobCursor);
+
+
console.log(
+
`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`,
+
);
+
+
// Reset cursor for actual migration
+
blobCursor = undefined;
+
pageCount = 0;
+
processedBlobs = 0;
+
+
do {
+
const pageStartTime = Date.now();
+
console.log(
+
`[${new Date().toISOString()}] Fetching blob list page ${
+
pageCount + 1
+
}...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Fetching blob list page ${
+
pageCount + 1
+
}...`,
+
);
+
+
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
+
did: accountDid,
+
cursor: blobCursor,
+
});
+
+
const pageTime = Date.now() - pageStartTime;
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Found ${listedBlobs.data.cids.length} blobs on page ${
+
pageCount + 1
+
} in ${pageTime / 1000} seconds`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Found ${listedBlobs.data.cids.length} blobs on page ${
+
pageCount + 1
+
} in ${pageTime / 1000} seconds`,
+
);
+
+
blobCursor = listedBlobs.data.cursor;
+
+
for (const cid of listedBlobs.data.cids) {
+
try {
+
const blobStartTime = Date.now();
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Starting migration for blob ${cid} (${
+
processedBlobs + 1
+
} of ${totalBlobs})...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Starting migration for blob ${cid} (${
+
processedBlobs + 1
+
} of ${totalBlobs})...`,
+
);
+
+
const blobRes = await oldAgent.com.atproto.sync.getBlob({
+
did: accountDid,
+
cid,
+
});
+
+
const contentLength = blobRes.headers["content-length"];
+
if (!contentLength) {
+
throw new Error(`Blob ${cid} has no content length`);
+
}
+
+
const size = parseInt(contentLength, 10);
+
if (isNaN(size)) {
+
throw new Error(
+
`Blob ${cid} has invalid content length: ${contentLength}`,
+
);
+
}
+
+
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
+
if (size > MAX_SIZE) {
+
throw new Error(
+
`Blob ${cid} exceeds maximum size limit (${size} bytes)`,
+
);
+
}
+
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Downloading blob ${cid} (${size} bytes)...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Downloading blob ${cid} (${size} bytes)...`,
+
);
+
+
if (!blobRes.data) {
+
throw new Error(
+
`Failed to download blob ${cid}: No data received`,
+
);
+
}
+
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Uploading blob ${cid} to new account...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Uploading blob ${cid} to new account...`,
+
);
+
+
try {
+
await newAgent.com.atproto.repo.uploadBlob(blobRes.data);
+
const blobTime = Date.now() - blobStartTime;
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Successfully migrated blob ${cid} in ${
+
blobTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Successfully migrated blob ${cid} in ${
+
blobTime / 1000
+
} seconds`,
+
);
+
migratedBlobs.push(cid);
+
} catch (uploadError) {
+
console.error(
+
`[${new Date().toISOString()}] Failed to upload blob ${cid}:`,
+
uploadError,
+
);
+
throw new Error(
+
`Upload failed: ${
+
uploadError instanceof Error
+
? uploadError.message
+
: String(uploadError)
+
}`,
+
);
+
}
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
const detailedError = `[${
+
new Date().toISOString()
+
}] Failed to migrate blob ${cid}: ${errorMessage}`;
+
console.error(detailedError);
+
console.error("Full error details:", error);
+
migrationLogs.push(detailedError);
+
failedBlobs.push(cid);
+
}
+
+
processedBlobs++;
+
const progressLog = `[${
+
new Date().toISOString()
+
}] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${
+
Math.round((processedBlobs / totalBlobs) * 100)
+
}%)`;
+
console.log(progressLog);
+
migrationLogs.push(progressLog);
+
}
+
pageCount++;
+
} while (blobCursor);
+
+
const totalTime = Date.now() - startTime;
+
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)`;
+
console.log(completionMessage);
+
migrationLogs.push(completionMessage);
+
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: failedBlobs.length > 0
+
? `Blob migration completed with ${failedBlobs.length} failed blobs`
+
: "Blob migration completed successfully",
+
migratedBlobs,
+
failedBlobs,
+
totalMigrated: migratedBlobs.length,
+
totalFailed: failedBlobs.length,
+
totalProcessed: processedBlobs,
+
totalBlobs,
+
logs: migrationLogs,
+
timing: {
+
totalTime: totalTime / 1000,
+
},
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
} catch (error) {
+
const message = error instanceof Error ? error.message : String(error);
+
console.error(
+
`[${new Date().toISOString()}] Blob migration error:`,
+
message,
+
);
+
console.error("Full error details:", error);
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: `Blob migration failed: ${message}`,
+
error: error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
+
}),
+
{
+
status: 500,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
}
+
},
+
});
+163
routes/api/migrate/data/prefs.ts
···
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
+
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
+
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
+
console.log("Preferences migration: Starting session retrieval");
+
const oldAgent = await getSessionAgent(ctx.req);
+
console.log("Preferences migration: Got old agent:", !!oldAgent);
+
+
const newAgent = await getSessionAgent(ctx.req, res, true);
+
console.log("Preferences migration: Got new agent:", !!newAgent);
+
+
if (!oldAgent || !newAgent) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Not authenticated",
+
}),
+
{
+
status: 401,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Migrate preferences
+
const migrationLogs: string[] = [];
+
const startTime = Date.now();
+
console.log(
+
`[${new Date().toISOString()}] Starting preferences migration...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting preferences migration...`,
+
);
+
+
// Fetch preferences
+
console.log(
+
`[${
+
new Date().toISOString()
+
}] Fetching preferences from old account...`,
+
);
+
migrationLogs.push(
+
`[${
+
new Date().toISOString()
+
}] Fetching preferences from old account...`,
+
);
+
+
const fetchStartTime = Date.now();
+
const prefs = await oldAgent.app.bsky.actor.getPreferences();
+
const fetchTime = Date.now() - fetchStartTime;
+
+
console.log(
+
`[${new Date().toISOString()}] Preferences fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Preferences fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
+
+
// Update preferences
+
console.log(
+
`[${new Date().toISOString()}] Updating preferences on new account...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Updating preferences on new account...`,
+
);
+
+
const updateStartTime = Date.now();
+
await newAgent.app.bsky.actor.putPreferences(prefs.data);
+
const updateTime = Date.now() - updateStartTime;
+
+
console.log(
+
`[${new Date().toISOString()}] Preferences updated in ${
+
updateTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Preferences updated in ${
+
updateTime / 1000
+
} seconds`,
+
);
+
+
const totalTime = Date.now() - startTime;
+
const completionMessage = `[${
+
new Date().toISOString()
+
}] Preferences migration completed in ${totalTime / 1000} seconds total`;
+
console.log(completionMessage);
+
migrationLogs.push(completionMessage);
+
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: "Preferences migration completed successfully",
+
logs: migrationLogs,
+
timing: {
+
fetchTime: fetchTime / 1000,
+
updateTime: updateTime / 1000,
+
totalTime: totalTime / 1000,
+
},
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
} catch (error) {
+
const message = error instanceof Error ? error.message : String(error);
+
console.error(
+
`[${new Date().toISOString()}] Preferences migration error:`,
+
message,
+
);
+
console.error("Full error details:", error);
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: `Preferences migration failed: ${message}`,
+
error: error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
+
}),
+
{
+
status: 500,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
}
+
},
+
});
+163
routes/api/migrate/data/repo.ts
···
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
+
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
+
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
+
console.log("Repo migration: Starting session retrieval");
+
const oldAgent = await getSessionAgent(ctx.req);
+
console.log("Repo migration: Got old agent:", !!oldAgent);
+
+
const newAgent = await getSessionAgent(ctx.req, res, true);
+
console.log("Repo migration: Got new agent:", !!newAgent);
+
+
if (!oldAgent || !newAgent) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Not authenticated",
+
}),
+
{
+
status: 401,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
const session = await oldAgent.com.atproto.server.getSession();
+
const accountDid = session.data.did;
+
// Migrate repo data
+
const migrationLogs: string[] = [];
+
const startTime = Date.now();
+
console.log(`[${new Date().toISOString()}] Starting repo migration...`);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Starting repo migration...`,
+
);
+
+
// Get repo data from old account
+
console.log(
+
`[${new Date().toISOString()}] Fetching repo data from old account...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Fetching repo data from old account...`,
+
);
+
+
const fetchStartTime = Date.now();
+
const repoData = await oldAgent.com.atproto.sync.getRepo({
+
did: accountDid,
+
});
+
const fetchTime = Date.now() - fetchStartTime;
+
+
console.log(
+
`[${new Date().toISOString()}] Repo data fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Repo data fetched in ${
+
fetchTime / 1000
+
} seconds`,
+
);
+
+
console.log(
+
`[${new Date().toISOString()}] Importing repo data to new account...`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Importing repo data to new account...`,
+
);
+
+
// Import repo data to new account
+
const importStartTime = Date.now();
+
await newAgent.com.atproto.repo.importRepo(repoData.data, {
+
encoding: "application/vnd.ipld.car",
+
});
+
const importTime = Date.now() - importStartTime;
+
+
console.log(
+
`[${new Date().toISOString()}] Repo data imported in ${
+
importTime / 1000
+
} seconds`,
+
);
+
migrationLogs.push(
+
`[${new Date().toISOString()}] Repo data imported in ${
+
importTime / 1000
+
} seconds`,
+
);
+
+
const totalTime = Date.now() - startTime;
+
const completionMessage = `[${
+
new Date().toISOString()
+
}] Repo migration completed in ${totalTime / 1000} seconds total`;
+
console.log(completionMessage);
+
migrationLogs.push(completionMessage);
+
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: "Repo migration completed successfully",
+
logs: migrationLogs,
+
timing: {
+
fetchTime: fetchTime / 1000,
+
importTime: importTime / 1000,
+
totalTime: totalTime / 1000,
+
},
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
} catch (error) {
+
const message = error instanceof Error ? error.message : String(error);
+
console.error(
+
`[${new Date().toISOString()}] Repo migration error:`,
+
message,
+
);
+
console.error("Full error details:", error);
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: `Repo migration failed: ${message}`,
+
error: error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
+
}),
+
{
+
status: 500,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
}
+
},
+
});
-273
routes/api/migrate/data.ts
···
-
import { define } from "../../../utils.ts";
-
import {
-
getSessionAgent,
-
} from "../../../lib/sessions.ts";
-
import { Agent, ComAtprotoSyncGetBlob } from "npm:@atproto/api";
-
-
// Retry configuration
-
const MAX_RETRIES = 3;
-
const INITIAL_RETRY_DELAY = 1000; // 1 second
-
-
interface RetryOptions {
-
maxRetries?: number;
-
initialDelay?: number;
-
onRetry?: (attempt: number, error: Error) => void;
-
}
-
-
async function withRetry<T>(
-
operation: () => Promise<T>,
-
options: RetryOptions = {},
-
): Promise<T> {
-
const maxRetries = options.maxRetries ?? MAX_RETRIES;
-
const initialDelay = options.initialDelay ?? INITIAL_RETRY_DELAY;
-
-
let lastError: Error | null = null;
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
-
try {
-
return await operation();
-
} catch (error) {
-
lastError = error instanceof Error ? error : new Error(String(error));
-
-
// Don't retry on certain errors
-
if (error instanceof Error) {
-
// Don't retry on permanent errors like authentication
-
if (error.message.includes("Unauthorized") || error.message.includes("Invalid token")) {
-
throw error;
-
}
-
}
-
-
if (attempt < maxRetries - 1) {
-
const delay = initialDelay * Math.pow(2, attempt);
-
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms:`, lastError.message);
-
if (options.onRetry) {
-
options.onRetry(attempt + 1, lastError);
-
}
-
await new Promise(resolve => setTimeout(resolve, delay));
-
}
-
}
-
}
-
throw lastError ?? new Error("Operation failed after retries");
-
}
-
-
async function handleBlobUpload(
-
newAgent: Agent,
-
blobRes: ComAtprotoSyncGetBlob.Response,
-
cid: string
-
) {
-
try {
-
const contentLength = parseInt(blobRes.headers["content-length"] || "0", 10);
-
const contentType = blobRes.headers["content-type"];
-
-
// Check file size before attempting upload
-
const MAX_SIZE = 95 * 1024 * 1024; // 95MB to be safe
-
if (contentLength > MAX_SIZE) {
-
throw new Error(`Blob ${cid} exceeds maximum size limit (${contentLength} bytes)`);
-
}
-
-
await withRetry(
-
() => newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
-
encoding: contentType,
-
}),
-
{
-
maxRetries: 5,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying blob upload for ${cid} (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
} catch (error) {
-
console.error(`Failed to upload blob ${cid}:`, error);
-
throw error;
-
}
-
}
-
-
export const handler = define.handlers({
-
async POST(ctx) {
-
const res = new Response();
-
try {
-
console.log("Data migration: Starting session retrieval");
-
const oldAgent = await getSessionAgent(ctx.req);
-
console.log("Data migration: Got old agent:", !!oldAgent);
-
-
// Log cookie information
-
const cookies = ctx.req.headers.get("cookie");
-
console.log("Data migration: Cookies present:", !!cookies);
-
console.log("Data migration: Cookie header:", cookies);
-
-
const newAgent = await getSessionAgent(ctx.req, res, true);
-
console.log("Data migration: Got new agent:", !!newAgent);
-
-
if (!oldAgent) {
-
return new Response(
-
JSON.stringify({
-
success: false,
-
message: "Unauthorized",
-
}),
-
{
-
status: 401,
-
headers: { "Content-Type": "application/json" },
-
},
-
);
-
}
-
if (!newAgent) {
-
return new Response(
-
JSON.stringify({
-
success: false,
-
message: "Migration session not found or invalid",
-
}),
-
{
-
status: 400,
-
headers: { "Content-Type": "application/json" },
-
},
-
);
-
}
-
-
const session = await oldAgent.com.atproto.server.getSession();
-
const accountDid = session.data.did;
-
-
// Migrate repo data with retries
-
const repoRes = await withRetry(
-
() => oldAgent.com.atproto.sync.getRepo({
-
did: accountDid,
-
}),
-
{
-
maxRetries: 5,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying repo fetch (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
-
await withRetry(
-
() => newAgent.com.atproto.repo.importRepo(repoRes.data, {
-
encoding: "application/vnd.ipld.car",
-
}),
-
{
-
maxRetries: 5,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying repo import (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
-
// Migrate blobs with enhanced error handling
-
let blobCursor: string | undefined = undefined;
-
const migratedBlobs: string[] = [];
-
const failedBlobs: Array<{ cid: string; error: string }> = [];
-
-
do {
-
try {
-
const listedBlobs = await withRetry(
-
() => oldAgent.com.atproto.sync.listBlobs({
-
did: accountDid,
-
cursor: blobCursor,
-
}),
-
{
-
maxRetries: 5,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying blob list fetch (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
-
for (const cid of listedBlobs.data.cids) {
-
try {
-
const blobRes = await withRetry(
-
() => oldAgent.com.atproto.sync.getBlob({
-
did: accountDid,
-
cid,
-
}),
-
{
-
maxRetries: 5,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying blob download for ${cid} (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
-
await handleBlobUpload(newAgent, blobRes, cid);
-
migratedBlobs.push(cid);
-
console.log(`Successfully migrated blob: ${cid}`);
-
} catch (error) {
-
console.error(`Failed to migrate blob ${cid}:`, error);
-
failedBlobs.push({
-
cid,
-
error: error instanceof Error ? error.message : String(error),
-
});
-
}
-
}
-
blobCursor = listedBlobs.data.cursor;
-
} catch (error) {
-
console.error("Error during blob migration batch:", error);
-
// If we hit a critical error during blob listing, break the loop
-
if (error instanceof Error &&
-
(error.message.includes("Unauthorized") ||
-
error.message.includes("Invalid token"))) {
-
throw error;
-
}
-
break;
-
}
-
} while (blobCursor);
-
-
// Migrate preferences with retry
-
const prefs = await withRetry(
-
() => oldAgent.app.bsky.actor.getPreferences(),
-
{
-
maxRetries: 3,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying preferences fetch (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
-
await withRetry(
-
() => newAgent.app.bsky.actor.putPreferences(prefs.data),
-
{
-
maxRetries: 3,
-
onRetry: (attempt, error) => {
-
console.log(`Retrying preferences update (attempt ${attempt}):`, error.message);
-
},
-
}
-
);
-
-
return new Response(
-
JSON.stringify({
-
success: true,
-
message: failedBlobs.length > 0
-
? `Data migration completed with ${failedBlobs.length} failed blobs`
-
: "Data migration completed successfully",
-
migratedBlobs,
-
failedBlobs,
-
totalMigrated: migratedBlobs.length,
-
totalFailed: failedBlobs.length,
-
}),
-
{
-
status: failedBlobs.length > 0 ? 207 : 200, // Use 207 Multi-Status if some blobs failed
-
headers: {
-
"Content-Type": "application/json",
-
...Object.fromEntries(res.headers), // Include session cookie headers
-
},
-
},
-
);
-
} catch (error) {
-
console.error("Data migration error:", error);
-
return new Response(
-
JSON.stringify({
-
success: false,
-
message: error instanceof Error
-
? error.message
-
: "Failed to migrate data",
-
error: error instanceof Error ? {
-
name: error.name,
-
message: error.message,
-
stack: error.stack,
-
} : String(error),
-
}),
-
{
-
status: 400,
-
headers: { "Content-Type": "application/json" },
-
},
-
);
-
}
-
},
-
});
+17
routes/api/migrate/finalize.ts
···
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../lib/check-dids.ts";
import { define } from "../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../lib/migration-state.ts";
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
const oldAgent = await getSessionAgent(ctx.req);
const newAgent = await getSessionAgent(ctx.req, res, true);
···
return new Response("Migration session not found or invalid", {
status: 400,
});
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{ status: 400, headers: { "Content-Type": "application/json" } },
+
);
}
// Activate new account and deactivate old account
+29 -4
routes/api/migrate/identity/request.ts
···
-
import {
-
getSessionAgent,
-
} from "../../../../lib/sessions.ts";
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
+
/**
+
* Handle identity migration request
+
* Sends a PLC operation signature request to the old account's email
+
* Should be called after all data is migrated to the new account
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the migration result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
+
console.log("Starting identity migration request...");
const oldAgent = await getSessionAgent(ctx.req);
console.log("Got old agent:", {
···
);
}
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
// Request the signature
console.log("Requesting PLC operation signature...");
try {
···
console.error("Error requesting PLC operation signature:", {
name: error instanceof Error ? error.name : "Unknown",
message: error instanceof Error ? error.message : String(error),
-
status: 400
+
status: 400,
});
throw error;
}
+27 -3
routes/api/migrate/identity/sign.ts
···
-
import {
-
getSessionAgent,
-
} from "../../../../lib/sessions.ts";
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
import { Secp256k1Keypair } from "npm:@atproto/crypto";
import * as ui8 from "npm:uint8arrays";
import { define } from "../../../../utils.ts";
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
+
/**
+
* Handle identity migration sign
+
* Should be called after user receives the migration token via email
+
* URL params must contain the token
+
* @param ctx - The context object containing the request with the token in the URL params
+
* @returns A response object with the migration result
+
*/
export const handler = define.handlers({
async POST(ctx) {
const res = new Response();
try {
+
// Check if migrations are currently allowed
+
assertMigrationAllowed();
const url = new URL(ctx.req.url);
const token = url.searchParams.get("token");
···
JSON.stringify({
success: false,
message: "Migration session not found or invalid",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
+
// Verify DIDs match between sessions
+
const didsMatch = await checkDidsMatch(ctx.req);
+
if (!didsMatch) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Invalid state, original and target DIDs do not match",
}),
{
status: 400,
+2 -2
routes/api/migrate/next-step.ts
···
// Check conditions in sequence to determine the next step
if (!newStatus.data) {
nextStep = 1;
-
} else if (!(newStatus.data.repoCommit &&
+
} else if (!(newStatus.data.repoCommit &&
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
···
}
});
}
-
})
+
})
+135 -71
routes/api/migrate/status.ts
···
+
import { checkDidsMatch } from "../../../lib/check-dids.ts";
import { getSessionAgent } from "../../../lib/sessions.ts";
import { define } from "../../../utils.ts";
export const handler = define.handlers({
-
async GET(ctx) {
-
const url = new URL(ctx.req.url);
-
const params = new URLSearchParams(url.search);
-
const step = params.get("step");
-
const oldAgent = await getSessionAgent(ctx.req);
-
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
-
-
if (!oldAgent || !newAgent) return new Response("Unauthorized", { status: 401 });
+
async GET(ctx) {
+
console.log("Status check: Starting");
+
const url = new URL(ctx.req.url);
+
const params = new URLSearchParams(url.search);
+
const step = params.get("step");
+
console.log("Status check: Step", step);
-
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
-
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
-
if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 });
+
console.log("Status check: Getting agents");
+
const oldAgent = await getSessionAgent(ctx.req);
+
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
-
const readyToContinue = () => {
-
if (step) {
-
switch (step) {
-
case "1": {
-
if (newStatus.data) {
-
return { ready: true };
-
}
-
return { ready: false, reason: "New account status not available" };
-
}
-
case "2": {
-
if (newStatus.data.repoCommit &&
-
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
-
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
-
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
-
newStatus.data.importedBlobs === oldStatus.data.importedBlobs) {
-
return { ready: true };
-
}
-
const reasons = [];
-
if (!newStatus.data.repoCommit) reasons.push("Repository not imported.");
-
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords)
-
reasons.push("Not all records imported.");
-
if (newStatus.data.privateStateValues < oldStatus.data.privateStateValues)
-
reasons.push("Not all private state values imported.");
-
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs)
-
reasons.push("Expected blobs not fully imported.");
-
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs)
-
reasons.push("Not all blobs imported.");
-
return { ready: false, reason: reasons.join(", ") };
-
}
-
case "3": {
-
if (newStatus.data.validDid) {
-
return { ready: true };
-
}
-
return { ready: false, reason: "DID not valid" };
-
}
-
case "4": {
-
if (newStatus.data.activated === true && oldStatus.data.activated === false) {
-
return { ready: true };
-
}
-
return { ready: false, reason: "Account not activated" };
-
}
-
}
-
} else {
-
return { ready: true };
+
if (!oldAgent || !newAgent) {
+
console.log("Status check: Unauthorized - missing agents", {
+
hasOldAgent: !!oldAgent,
+
hasNewAgent: !!newAgent,
+
});
+
return new Response("Unauthorized", { status: 401 });
+
}
+
+
const didsMatch = await checkDidsMatch(ctx.req);
+
+
console.log("Status check: Fetching account statuses");
+
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
+
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
+
+
if (!oldStatus.data || !newStatus.data) {
+
console.error("Status check: Failed to verify status", {
+
hasOldStatus: !!oldStatus.data,
+
hasNewStatus: !!newStatus.data,
+
});
+
return new Response("Could not verify status", { status: 500 });
+
}
+
+
console.log("Status check: Account statuses", {
+
old: oldStatus.data,
+
new: newStatus.data,
+
});
+
+
const readyToContinue = () => {
+
if (!didsMatch) {
+
return {
+
ready: false,
+
reason: "Invalid state, original and target DIDs do not match",
+
};
+
}
+
if (step) {
+
console.log("Status check: Evaluating step", step);
+
switch (step) {
+
case "1": {
+
if (newStatus.data) {
+
console.log("Status check: Step 1 ready");
+
return { ready: true };
}
+
console.log(
+
"Status check: Step 1 not ready - new account status not available",
+
);
+
return { ready: false, reason: "New account status not available" };
+
}
+
case "2": {
+
const isReady = newStatus.data.repoCommit &&
+
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
+
newStatus.data.privateStateValues ===
+
oldStatus.data.privateStateValues &&
+
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
+
newStatus.data.importedBlobs === oldStatus.data.importedBlobs;
+
+
if (isReady) {
+
console.log("Status check: Step 2 ready");
+
return { ready: true };
+
}
+
+
const reasons = [];
+
if (!newStatus.data.repoCommit) {
+
reasons.push("Repository not imported.");
+
}
+
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords) {
+
reasons.push("Not all records imported.");
+
}
+
if (
+
newStatus.data.privateStateValues <
+
oldStatus.data.privateStateValues
+
) {
+
reasons.push("Not all private state values imported.");
+
}
+
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs) {
+
reasons.push("Expected blobs not fully imported.");
+
}
+
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs) {
+
reasons.push("Not all blobs imported.");
+
}
+
+
console.log("Status check: Step 2 not ready", { reasons });
+
return { ready: false, reason: reasons.join(", ") };
+
}
+
case "3": {
+
if (newStatus.data.validDid) {
+
console.log("Status check: Step 3 ready");
+
return { ready: true };
+
}
+
console.log("Status check: Step 3 not ready - DID not valid");
+
return { ready: false, reason: "DID not valid" };
+
}
+
case "4": {
+
if (
+
newStatus.data.activated === true &&
+
oldStatus.data.activated === false
+
) {
+
console.log("Status check: Step 4 ready");
+
return { ready: true };
+
}
+
console.log(
+
"Status check: Step 4 not ready - Account not activated",
+
);
+
return { ready: false, reason: "Account not activated" };
+
}
}
+
} else {
+
console.log("Status check: No step specified, returning ready");
+
return { ready: true };
+
}
+
};
-
const status = {
-
activated: newStatus.data.activated,
-
validDid: newStatus.data.validDid,
-
repoCommit: newStatus.data.repoCommit,
-
repoRev: newStatus.data.repoRev,
-
repoBlocks: newStatus.data.repoBlocks,
-
expectedRecords: oldStatus.data.indexedRecords,
-
indexedRecords: newStatus.data.indexedRecords,
-
privateStateValues: newStatus.data.privateStateValues,
-
expectedBlobs: newStatus.data.expectedBlobs,
-
importedBlobs: newStatus.data.importedBlobs,
-
...readyToContinue()
-
}
+
const status = {
+
activated: newStatus.data.activated,
+
validDid: newStatus.data.validDid,
+
repoCommit: newStatus.data.repoCommit,
+
repoRev: newStatus.data.repoRev,
+
repoBlocks: newStatus.data.repoBlocks,
+
expectedRecords: oldStatus.data.indexedRecords,
+
indexedRecords: newStatus.data.indexedRecords,
+
privateStateValues: newStatus.data.privateStateValues,
+
expectedBlobs: newStatus.data.expectedBlobs,
+
importedBlobs: newStatus.data.importedBlobs,
+
...readyToContinue(),
+
};
-
return Response.json(status);
-
}
-
})
+
console.log("Status check: Complete", status);
+
return Response.json(status);
+
},
+
});
+44
routes/api/migration-state.ts
···
+
import { getMigrationState } from "../../lib/migration-state.ts";
+
import { define } from "../../utils.ts";
+
+
/**
+
* API endpoint to check the current migration state.
+
* Returns the migration state information including whether migrations are allowed.
+
*/
+
export const handler = define.handlers({
+
GET(_ctx) {
+
try {
+
const stateInfo = getMigrationState();
+
+
return new Response(
+
JSON.stringify({
+
state: stateInfo.state,
+
message: stateInfo.message,
+
allowMigration: stateInfo.allowMigration,
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
},
+
}
+
);
+
} catch (error) {
+
console.error("Error checking migration state:", error);
+
+
return new Response(
+
JSON.stringify({
+
state: "issue",
+
message: "Unable to determine migration state. Please try again later.",
+
allowMigration: false,
+
}),
+
{
+
status: 500,
+
headers: {
+
"Content-Type": "application/json",
+
},
+
}
+
);
+
}
+
},
+
});
+42
routes/api/plc/keys.ts
···
+
import { Secp256k1Keypair } from "@atproto/crypto";
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { define } from "../../../utils.ts";
+
import * as ui8 from "npm:uint8arrays";
+
+
/**
+
* Generate and return PLC keys for the authenticated user
+
*/
+
export const handler = define.handlers({
+
async GET(ctx) {
+
const agent = await getSessionAgent(ctx.req);
+
if (!agent) {
+
return new Response("Unauthorized", { status: 401 });
+
}
+
+
// Create a new keypair
+
const keypair = await Secp256k1Keypair.create({ exportable: true });
+
+
// Export private key bytes
+
const privateKeyBytes = await keypair.export();
+
const privateKeyHex = ui8.toString(privateKeyBytes, "hex");
+
+
// Get public key as DID
+
const publicKeyDid = keypair.did();
+
+
// Convert private key to multikey format (base58btc)
+
const privateKeyMultikey = ui8.toString(privateKeyBytes, "base58btc");
+
+
// Return the key information
+
return new Response(
+
JSON.stringify({
+
keyType: "secp256k1",
+
publicKeyDid: publicKeyDid,
+
privateKeyHex: privateKeyHex,
+
privateKeyMultikey: privateKeyMultikey,
+
}),
+
{
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
},
+
});
+64
routes/api/plc/token.ts
···
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
+
import { Agent } from "@atproto/api";
+
import { define } from "../../../utils.ts";
+
+
/**
+
* Handle account creation
+
* First step of the migration process
+
* Body must contain:
+
* - service: The service URL of the new account
+
* - handle: The handle of the new account
+
* - password: The password of the new account
+
* - email: The email of the new account
+
* - invite: The invite code of the new account (optional depending on the PDS)
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the creation result
+
*/
+
export const handler = define.handlers({
+
async GET(ctx) {
+
const res = new Response();
+
try {
+
const agent = await getSessionAgent(ctx.req, res);
+
+
if (!agent) return new Response("Unauthorized", { status: 401 });
+
+
// console.log("getting did");
+
// const session = await agent.com.atproto.server.getSession();
+
// const accountDid = session.data.did;
+
// console.log("got did");
+
+
await agent.com.atproto.identity.requestPlcOperationSignature();
+
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message:
+
"We've requested a token to update your identity, it should be sent to your account's email address.",
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers), // Include session cookie headers
+
},
+
}
+
);
+
} catch (error) {
+
console.error("PLC signature request error:", error);
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message:
+
error instanceof Error
+
? error.message
+
: "Failed to get PLC operation signature (sending confirmation email)",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
},
+
});
+92
routes/api/plc/update/complete.ts
···
+
import { Agent } from "@atproto/api";
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
+
import { define } from "../../../../utils.ts";
+
+
/**
+
* Complete PLC update using email token
+
*/
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
const url = new URL(ctx.req.url);
+
const token = url.searchParams.get("token");
+
+
if (!token) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Missing token parameter",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
+
const agent = await getSessionAgent(ctx.req, res, true);
+
if (!agent) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Unauthorized",
+
}),
+
{
+
status: 401,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
+
const did = agent.did;
+
if (!did) {
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID found in session",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
+
// Submit the PLC operation with the token
+
await agent!.com.atproto.identity.submitPlcOperation({
+
operation: { token: token },
+
});
+
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: "PLC update completed successfully",
+
did,
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers), // Include session cookie headers
+
},
+
}
+
);
+
} catch (error) {
+
console.error("PLC update completion error:", error);
+
const message =
+
error instanceof Error ? error.message : "Unknown error occurred";
+
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: `Failed to complete PLC update: ${message}`,
+
}),
+
{
+
status: 500,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
},
+
});
+155
routes/api/plc/update.ts
···
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { define } from "../../../utils.ts";
+
import * as plc from "@did-plc/lib";
+
+
/**
+
* Handle PLC update operation
+
* Body must contain:
+
* - key: The new rotation key to add
+
* - token: The email token received from requestPlcOperationSignature
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the update result
+
*/
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
console.log("=== PLC Update Debug ===");
+
const body = await ctx.req.json();
+
const { key: newKey, token } = body;
+
console.log("Request body:", { newKey, hasToken: !!token });
+
+
if (!newKey) {
+
console.log("Missing key in request");
+
return new Response("Missing param key in request body", {
+
status: 400,
+
});
+
}
+
+
if (!token) {
+
console.log("Missing token in request");
+
return new Response("Missing param token in request body", {
+
status: 400,
+
});
+
}
+
+
const agent = await getSessionAgent(ctx.req, res);
+
if (!agent) {
+
console.log("No agent found");
+
return new Response("Unauthorized", { status: 401 });
+
}
+
+
const session = await agent.com.atproto.server.getSession();
+
const did = session.data.did;
+
if (!did) {
+
console.log("No DID found in session");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID found in your session",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Using agent DID:", did);
+
+
// Get recommended credentials first
+
console.log("Getting did:plc document...");
+
const plcClient = new plc.Client("https://plc.directory");
+
const didDoc = await plcClient.getDocumentData(did);
+
if (!didDoc) {
+
console.log("No DID document found for agent DID");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID document found for your account",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Got DID document:", didDoc);
+
+
const rotationKeys = didDoc.rotationKeys ?? [];
+
if (!rotationKeys.length) {
+
console.log("No existing rotation keys found");
+
throw new Error("No rotation keys provided in recommended credentials");
+
}
+
+
// Check if the key is already in rotation keys
+
if (rotationKeys.includes(newKey)) {
+
console.log("Key already exists in rotation keys");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "This key is already in your rotation keys",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
+
// Perform the actual PLC update with the provided token
+
console.log("Signing PLC operation...");
+
const plcOp = await agent.com.atproto.identity.signPlcOperation({
+
token,
+
rotationKeys: [newKey, ...rotationKeys],
+
});
+
console.log("PLC operation signed successfully:", plcOp.data);
+
+
console.log("Submitting PLC operation...");
+
const plcSubmit = await agent.com.atproto.identity.submitPlcOperation({
+
operation: plcOp.data.operation,
+
});
+
console.log("PLC operation submitted successfully:", plcSubmit);
+
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: "PLC update completed successfully",
+
did: plcOp.data,
+
newKey,
+
rotationKeys: [newKey, ...rotationKeys],
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers), // Include session cookie headers
+
},
+
}
+
);
+
} catch (error) {
+
console.error("PLC update error:", error);
+
const errorMessage =
+
error instanceof Error ? error.message : "Failed to update your PLC";
+
console.log("Sending error response:", errorMessage);
+
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: errorMessage,
+
error:
+
error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
},
+
});
+131
routes/api/plc/verify.ts
···
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { define } from "../../../utils.ts";
+
import * as plc from "@did-plc/lib";
+
+
/**
+
* Verify if a rotation key exists in the PLC document
+
* Body must contain:
+
* - key: The rotation key to verify
+
* @param ctx - The context object containing the request and response
+
* @returns A response object with the verification result
+
*/
+
export const handler = define.handlers({
+
async POST(ctx) {
+
const res = new Response();
+
try {
+
const body = await ctx.req.json();
+
const { key: newKey } = body;
+
console.log("Request body:", { newKey });
+
+
if (!newKey) {
+
console.log("Missing key in request");
+
return new Response("Missing param key in request body", {
+
status: 400,
+
});
+
}
+
+
const agent = await getSessionAgent(ctx.req, res);
+
if (!agent) {
+
console.log("No agent found");
+
return new Response("Unauthorized", { status: 401 });
+
}
+
+
const session = await agent.com.atproto.server.getSession();
+
const did = session.data.did;
+
if (!did) {
+
console.log("No DID found in session");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID found in your session",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Using agent DID:", did);
+
+
// Fetch the PLC document to check rotation keys
+
console.log("Getting did:plc document...");
+
const plcClient = new plc.Client("https://plc.directory");
+
const didDoc = await plcClient.getDocumentData(did);
+
if (!didDoc) {
+
console.log("No DID document found for agent DID");
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "No DID document found for your account",
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
console.log("Got DID document:", didDoc);
+
+
const rotationKeys = didDoc.rotationKeys ?? [];
+
if (!rotationKeys.length) {
+
console.log("No existing rotation keys found");
+
throw new Error("No rotation keys found in did:plc document");
+
}
+
+
// Check if the key exists in rotation keys
+
if (rotationKeys.includes(newKey)) {
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message: "Rotation key exists in PLC document",
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers), // Include session cookie headers
+
},
+
}
+
);
+
}
+
+
// If we get here, the key was not found
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: "Rotation key not found in PLC document",
+
}),
+
{
+
status: 404,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
} catch (error) {
+
console.error("PLC verification error:", error);
+
const errorMessage =
+
error instanceof Error
+
? error.message
+
: "Failed to verify rotation key";
+
console.log("Sending error response:", errorMessage);
+
+
return new Response(
+
JSON.stringify({
+
success: false,
+
message: errorMessage,
+
error:
+
error instanceof Error
+
? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
}
+
: String(error),
+
}),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
}
+
);
+
}
+
},
+
});
+30
routes/api/resolve-pds.ts
···
+
import { resolver } from "../../lib/id-resolver.ts";
+
import { define } from "../../utils.ts";
+
+
export const handler = define.handlers({
+
async GET(ctx) {
+
const url = new URL(ctx.req.url);
+
const did = url.searchParams.get("did");
+
+
if (!did) {
+
return new Response(JSON.stringify({ error: "DID parameter is required" }), {
+
status: 400,
+
headers: { "Content-Type": "application/json" }
+
});
+
}
+
+
try {
+
const pds = await resolver.resolveDidToPdsUrl(did);
+
return new Response(JSON.stringify({ pds }), {
+
status: 200,
+
headers: { "Content-Type": "application/json" }
+
});
+
} catch (error) {
+
console.error("Failed to resolve PDS:", error);
+
return new Response(JSON.stringify({ error: "Failed to resolve PDS" }), {
+
status: 500,
+
headers: { "Content-Type": "application/json" }
+
});
+
}
+
}
+
});
+10 -13
routes/index.tsx
···
import Ticket from "../islands/Ticket.tsx";
-
import AirportSign from "../islands/AirportSign.tsx";
+
import AirportSign from "../components/AirportSign.tsx";
import SocialLinks from "../islands/SocialLinks.tsx";
-
import { Button } from "../components/Button.tsx";
+
import LoginButton from "../islands/LoginButton.tsx";
export default function Home() {
return (
···
<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">
Your terminal for seamless AT Protocol PDS migration and backup.
</p>
-
<p class="font-mono mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300">
-
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.
-
</p>
<Ticket />
-
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
-
<Button
-
href="/login"
-
color="blue"
-
label="BEGIN YOUR JOURNEY"
-
/>
-
</div>
-
<p class="font-mono text-lg sm:text-xl mb-4 mt-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300">
+
<LoginButton />
+
<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">
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.
</p>
+
<div class="text-center mb-4">
+
<a href="/about" class="inline-flex items-center text-blue-500 hover:text-blue-600 transition-colors">
+
<img src="/icons/info_bold.svg" alt="Info" class="w-5 h-5 mr-2" />
+
<span class="font-mono">Learn more about AT Protocol</span>
+
</a>
+
</div>
<SocialLinks />
</div>
</div>
+2 -2
routes/migrate/progress.tsx
···
if (!service || !handle || !email || !password) {
return (
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
+
<div class="bg-gray-50 dark:bg-gray-900 p-4">
<div class="max-w-2xl mx-auto">
<div class="bg-red-50 dark:bg-red-900 p-4 rounded-lg">
<p class="text-red-800 dark:text-red-200">
···
}
return (
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
+
<div class="bg-gray-50 dark:bg-gray-900 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8">
Migration Progress
+19
routes/ticket-booth/index.tsx
···
+
import { PageProps } from "fresh";
+
import MigrationSetup from "../../islands/MigrationSetup.tsx";
+
import DidPlcProgress from "../../islands/DidPlcProgress.tsx";
+
+
export default function TicketBooth(props: PageProps) {
+
const service = props.url.searchParams.get("service");
+
const handle = props.url.searchParams.get("handle");
+
+
return (
+
<div class=" bg-gray-50 dark:bg-gray-900 p-4">
+
<div class="max-w-2xl mx-auto">
+
<h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8">
+
Ticket Booth Self-Service Kiosk
+
</h1>
+
<DidPlcProgress />
+
</div>
+
</div>
+
);
+
}
+4
static/icons/account.svg
···
+
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3">
+
<path d="M0 0h24v24H0z" fill="none"/>
+
<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"/>
+
</svg>
+6
static/icons/info_bold.svg
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+
<path d="M12 16V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+
<path d="M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+
</svg>