.DS_Store
.DS_Store
This is a binary file and will not be displayed.
+3
.env.example
+3
.env.example
+2
.gitignore
+2
.gitignore
+35
-9
.zed/settings.json
+35
-9
.zed/settings.json
···
1
+
// Folder-specific settings
2
+
//
3
+
// For a full list of overridable settings, and general information on folder-specific settings,
4
+
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
1
5
{
6
+
"lsp": {
7
+
"deno": {
8
+
"settings": {
9
+
"deno": {
10
+
"enable": true,
11
+
"cacheOnSave": true,
12
+
"suggest": {
13
+
"imports": {
14
+
"autoDiscover": true
15
+
}
16
+
}
17
+
}
18
+
}
19
+
}
20
+
},
2
21
"languages": {
22
+
"JavaScript": {
23
+
"language_servers": [
24
+
"deno",
25
+
"!vtsls",
26
+
"!eslint",
27
+
"..."
28
+
]
29
+
},
3
30
"TypeScript": {
4
31
"language_servers": [
5
-
"wakatime",
6
32
"deno",
7
33
"!typescript-language-server",
8
34
"!vtsls",
9
-
"!eslint"
10
-
],
11
-
"formatter": "language_server"
35
+
"!eslint",
36
+
"..."
37
+
]
12
38
},
13
39
"TSX": {
14
40
"language_servers": [
15
-
"wakatime",
16
41
"deno",
17
42
"!typescript-language-server",
18
43
"!vtsls",
19
-
"!eslint"
20
-
],
21
-
"formatter": "language_server"
44
+
"!eslint",
45
+
"..."
46
+
]
22
47
}
23
-
}
48
+
},
49
+
"formatter": "language_server"
24
50
}
+7
-16
README.md
+7
-16
README.md
···
4
4
5
5
Airport is a web application built with Fresh and Deno that helps users safely migrate and backup their Bluesky PDS data. It provides a user-friendly interface for managing your AT Protocol data.
6
6
7
-
โ ๏ธ **Alpha Status**: Airport is currently in alpha. Please use migration tools at your own risk and avoid using with main accounts during this phase.
8
-
9
7
## Features
10
8
11
9
- PDS migration between servers
···
13
11
- User-friendly interface
14
12
- Coming soon: PLC Key retrieval, data backup
15
13
16
-
## Technology Stack
14
+
## Tech Stack
17
15
18
-
- [Fresh](https://fresh.deno.dev/) - The next-gen web framework
19
-
- [Deno](https://deno.com/) - A modern runtime for JavaScript and TypeScript
20
-
- [Tailwind CSS](https://tailwindcss.com/) - For styling
21
-
- AT Protocol Integration
16
+
- [Fresh](https://fresh.deno.dev/) - Web Framework
17
+
- [Deno](https://deno.com/) - Runtime
18
+
- [Tailwind](https://tailwindcss.com/) - Styling
22
19
23
-
## Getting Started
24
-
25
-
### Prerequisites
20
+
## Development
26
21
27
-
Make sure to install Deno:
22
+
Make sure you have Deno installed:
28
23
https://docs.deno.com/runtime/getting_started/installation
29
24
30
-
### Development
31
-
32
25
Start the project in development mode:
33
26
34
-
```
27
+
```shell
35
28
deno task dev
36
29
```
37
-
38
-
This will watch the project directory and restart as necessary.
39
30
40
31
## About
41
32
+6
components/AirportSign.tsx
+6
components/AirportSign.tsx
···
1
+
/**
2
+
* The airport sign component, used on the landing page.
3
+
* Looks like a physical airport sign with a screen.
4
+
* @returns The airport sign component
5
+
* @component
6
+
*/
1
7
export default function AirportSign() {
2
8
return (
3
9
<div class="relative inline-block mb-8 sm:mb-12">
+10
components/Button.tsx
+10
components/Button.tsx
···
12
12
type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>;
13
13
type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string };
14
14
15
+
/**
16
+
* The button props or anchor props for a button or link.
17
+
* @type {Props}
18
+
*/
15
19
type Props = ButtonProps | AnchorProps;
16
20
21
+
/**
22
+
* Styled button component.
23
+
* @param props - The button props
24
+
* @returns The button component
25
+
* @component
26
+
*/
17
27
export function Button(props: Props) {
18
28
const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props;
19
29
const isAnchor = 'href' in props;
+65
components/Link.tsx
+65
components/Link.tsx
···
1
+
import { JSX } from "preact";
2
+
3
+
/**
4
+
* Props for the Link component
5
+
*/
6
+
type Props = Omit<JSX.HTMLAttributes<HTMLAnchorElement>, "href"> & {
7
+
/** URL for the link */
8
+
href: string;
9
+
/** Whether this is an external link that should show an outbound icon */
10
+
isExternal?: boolean;
11
+
/** Link text content */
12
+
children: JSX.Element | string;
13
+
};
14
+
15
+
/**
16
+
* A link component that handles external links with appropriate styling and accessibility.
17
+
* Automatically adds external link icon and proper attributes for external links.
18
+
*/
19
+
export function Link(props: Props) {
20
+
const {
21
+
isExternal = false,
22
+
class: className = "",
23
+
children,
24
+
href,
25
+
...rest
26
+
} = props;
27
+
28
+
// SVG for external link icon
29
+
const externalLinkIcon = (
30
+
<svg
31
+
xmlns="http://www.w3.org/2000/svg"
32
+
viewBox="0 0 20 20"
33
+
fill="currentColor"
34
+
className="w-4 h-4 inline-block ml-1"
35
+
aria-hidden="true"
36
+
>
37
+
<path
38
+
fillRule="evenodd"
39
+
d="M4.25 5.5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 00.75-.75v-4a.75.75 0 011.5 0v4A2.25 2.25 0 0112.75 17h-8.5A2.25 2.25 0 012 14.75v-8.5A2.25 2.25 0 014.25 4h5a.75.75 0 010 1.5h-5z"
40
+
/>
41
+
<path
42
+
fillRule="evenodd"
43
+
d="M6.194 12.753a.75.75 0 001.06.053L16.5 4.44v2.81a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.553l-9.056 8.194a.75.75 0 00-.053 1.06z"
44
+
/>
45
+
</svg>
46
+
);
47
+
48
+
return (
49
+
<a
50
+
href={href}
51
+
{...rest}
52
+
className={`inline-flex items-center hover:underline ${className}`}
53
+
{...(isExternal && {
54
+
target: "_blank",
55
+
rel: "noopener noreferrer",
56
+
"aria-label": `${
57
+
typeof children === "string" ? children : ""
58
+
} (opens in new tab)`,
59
+
})}
60
+
>
61
+
{children}
62
+
{isExternal && externalLinkIcon}
63
+
</a>
64
+
);
65
+
}
+32
-8
deno.json
+32
-8
deno.json
···
8
8
},
9
9
"lint": {
10
10
"rules": {
11
-
"tags": ["fresh", "recommended"]
11
+
"tags": [
12
+
"fresh",
13
+
"recommended"
14
+
]
12
15
}
13
16
},
14
-
"exclude": ["**/_fresh/*"],
17
+
"exclude": [
18
+
"**/_fresh/*"
19
+
],
15
20
"imports": {
16
21
"@atproto/api": "npm:@atproto/api@^0.15.6",
17
22
"@bigmoves/atproto-oauth-client": "jsr:@bigmoves/atproto-oauth-client@^0.2.0",
···
21
26
"posthog-js": "npm:posthog-js@1.120.0",
22
27
"preact": "npm:preact@^10.26.6",
23
28
"@preact/signals": "npm:@preact/signals@^2.0.4",
24
-
"tailwindcss": "npm:tailwindcss@^3.4.3"
29
+
"tailwindcss": "npm:tailwindcss@^3.4.3",
30
+
"@atproto/crypto": "npm:@atproto/crypto@^0.4.4",
31
+
"@did-plc/lib": "npm:@did-plc/lib@^0.0.4"
25
32
},
26
33
"compilerOptions": {
27
-
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns"],
34
+
"lib": [
35
+
"dom",
36
+
"dom.asynciterable",
37
+
"dom.iterable",
38
+
"deno.ns"
39
+
],
28
40
"jsx": "precompile",
29
41
"jsxImportSource": "preact",
30
-
"jsxPrecompileSkipElements": ["a", "img", "source", "body", "html", "head"],
31
-
"types": ["node"]
42
+
"jsxPrecompileSkipElements": [
43
+
"a",
44
+
"img",
45
+
"source",
46
+
"body",
47
+
"html",
48
+
"head"
49
+
],
50
+
"types": [
51
+
"node"
52
+
]
32
53
},
33
-
"unstable": ["kv", "otel"]
34
-
}
54
+
"unstable": [
55
+
"kv",
56
+
"otel"
57
+
]
58
+
}
+284
-1
deno.lock
+284
-1
deno.lock
···
33
33
"npm:@atproto/api@*": "0.15.6",
34
34
"npm:@atproto/api@~0.15.6": "0.15.6",
35
35
"npm:@atproto/crypto@*": "0.4.4",
36
+
"npm:@atproto/crypto@~0.4.4": "0.4.4",
36
37
"npm:@atproto/identity@*": "0.4.8",
37
38
"npm:@atproto/jwk@0.1.4": "0.1.4",
38
39
"npm:@atproto/oauth-client@~0.3.13": "0.3.16",
39
40
"npm:@atproto/oauth-types@~0.2.4": "0.2.7",
40
41
"npm:@atproto/syntax@*": "0.4.0",
41
42
"npm:@atproto/xrpc@*": "0.7.0",
43
+
"npm:@did-plc/lib@^0.0.4": "0.0.4",
42
44
"npm:@lucide/lab@*": "0.1.2",
43
45
"npm:@opentelemetry/api@^1.9.0": "1.9.0",
44
46
"npm:@preact/signals@^1.2.3": "1.3.2_preact@10.26.6",
···
293
295
"zod"
294
296
]
295
297
},
298
+
"@atproto/common@0.1.1": {
299
+
"integrity": "sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==",
300
+
"dependencies": [
301
+
"@ipld/dag-cbor",
302
+
"multiformats@9.9.0",
303
+
"pino",
304
+
"zod"
305
+
]
306
+
},
307
+
"@atproto/crypto@0.1.0": {
308
+
"integrity": "sha512-9xgFEPtsCiJEPt9o3HtJT30IdFTGw5cQRSJVIy5CFhqBA4vDLcdXiRDLCjkzHEVbtNCsHUW6CrlfOgbeLPcmcg==",
309
+
"dependencies": [
310
+
"@noble/secp256k1",
311
+
"big-integer",
312
+
"multiformats@9.9.0",
313
+
"one-webcrypto",
314
+
"uint8arrays@3.0.0"
315
+
]
316
+
},
296
317
"@atproto/crypto@0.4.4": {
297
318
"integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==",
298
319
"dependencies": [
···
311
332
"integrity": "sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==",
312
333
"dependencies": [
313
334
"@atproto/common-web",
314
-
"@atproto/crypto"
335
+
"@atproto/crypto@0.4.4"
315
336
]
316
337
},
317
338
"@atproto/jwk@0.1.4": {
···
369
390
"integrity": "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==",
370
391
"dependencies": [
371
392
"@atproto/lexicon",
393
+
"zod"
394
+
]
395
+
},
396
+
"@did-plc/lib@0.0.4": {
397
+
"integrity": "sha512-Omeawq3b8G/c/5CtkTtzovSOnWuvIuCI4GTJNrt1AmCskwEQV7zbX5d6km1mjJNbE0gHuQPTVqZxLVqetNbfwA==",
398
+
"dependencies": [
399
+
"@atproto/common",
400
+
"@atproto/crypto@0.1.0",
401
+
"@ipld/dag-cbor",
402
+
"axios",
403
+
"multiformats@9.9.0",
404
+
"uint8arrays@3.0.0",
372
405
"zod"
373
406
]
374
407
},
···
492
525
"os": ["win32"],
493
526
"cpu": ["x64"]
494
527
},
528
+
"@ipld/dag-cbor@7.0.3": {
529
+
"integrity": "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA==",
530
+
"dependencies": [
531
+
"cborg",
532
+
"multiformats@9.9.0"
533
+
]
534
+
},
495
535
"@isaacs/cliui@8.0.2": {
496
536
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
497
537
"dependencies": [
···
538
578
},
539
579
"@noble/hashes@1.8.0": {
540
580
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
581
+
},
582
+
"@noble/secp256k1@1.7.2": {
583
+
"integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ=="
541
584
},
542
585
"@nodelib/fs.scandir@2.1.5": {
543
586
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
···
588
631
"undici-types"
589
632
]
590
633
},
634
+
"abort-controller@3.0.0": {
635
+
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
636
+
"dependencies": [
637
+
"event-target-shim"
638
+
]
639
+
},
591
640
"ansi-regex@5.0.1": {
592
641
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
593
642
},
···
616
665
"arg@5.0.2": {
617
666
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
618
667
},
668
+
"asynckit@0.4.0": {
669
+
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
670
+
},
671
+
"atomic-sleep@1.0.0": {
672
+
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="
673
+
},
619
674
"autoprefixer@10.4.17_postcss@8.4.35": {
620
675
"integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==",
621
676
"dependencies": [
···
632
687
"await-lock@2.2.2": {
633
688
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
634
689
},
690
+
"axios@1.10.0": {
691
+
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
692
+
"dependencies": [
693
+
"follow-redirects",
694
+
"form-data",
695
+
"proxy-from-env"
696
+
]
697
+
},
635
698
"balanced-match@1.0.2": {
636
699
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
700
+
},
701
+
"base64-js@1.5.1": {
702
+
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
703
+
},
704
+
"big-integer@1.6.52": {
705
+
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="
637
706
},
638
707
"binary-extensions@2.3.0": {
639
708
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="
···
663
732
],
664
733
"bin": true
665
734
},
735
+
"buffer@6.0.3": {
736
+
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
737
+
"dependencies": [
738
+
"base64-js",
739
+
"ieee754"
740
+
]
741
+
},
742
+
"call-bind-apply-helpers@1.0.2": {
743
+
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
744
+
"dependencies": [
745
+
"es-errors",
746
+
"function-bind"
747
+
]
748
+
},
666
749
"camelcase-css@2.0.1": {
667
750
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="
668
751
},
···
677
760
},
678
761
"caniuse-lite@1.0.30001717": {
679
762
"integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw=="
763
+
},
764
+
"cborg@1.10.2": {
765
+
"integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==",
766
+
"bin": true
680
767
},
681
768
"chokidar@3.6.0": {
682
769
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
···
705
792
"colord@2.9.3": {
706
793
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
707
794
},
795
+
"combined-stream@1.0.8": {
796
+
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
797
+
"dependencies": [
798
+
"delayed-stream"
799
+
]
800
+
},
708
801
"commander@4.1.1": {
709
802
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="
710
803
},
···
819
912
"css-tree@2.2.1"
820
913
]
821
914
},
915
+
"delayed-stream@1.0.0": {
916
+
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
917
+
},
822
918
"didyoumean@1.2.2": {
823
919
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
824
920
},
···
850
946
"domhandler"
851
947
]
852
948
},
949
+
"dunder-proto@1.0.1": {
950
+
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
951
+
"dependencies": [
952
+
"call-bind-apply-helpers",
953
+
"es-errors",
954
+
"gopd"
955
+
]
956
+
},
853
957
"eastasianwidth@0.2.0": {
854
958
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
855
959
},
···
865
969
"entities@4.5.0": {
866
970
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
867
971
},
972
+
"es-define-property@1.0.1": {
973
+
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
974
+
},
975
+
"es-errors@1.3.0": {
976
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
977
+
},
978
+
"es-object-atoms@1.1.1": {
979
+
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
980
+
"dependencies": [
981
+
"es-errors"
982
+
]
983
+
},
984
+
"es-set-tostringtag@2.1.0": {
985
+
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
986
+
"dependencies": [
987
+
"es-errors",
988
+
"get-intrinsic",
989
+
"has-tostringtag",
990
+
"hasown"
991
+
]
992
+
},
868
993
"esbuild-wasm@0.23.1": {
869
994
"integrity": "sha512-L3vn7ctvBrtScRfoB0zG1eOCiV4xYvpLYWfe6PDZuV+iDFDm4Mt3xeLIDllG8cDHQ8clUouK3XekulE+cxgkgw==",
870
995
"bin": true
···
903
1028
"escalade@3.2.0": {
904
1029
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
905
1030
},
1031
+
"event-target-shim@5.0.1": {
1032
+
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
1033
+
},
1034
+
"events@3.3.0": {
1035
+
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
1036
+
},
906
1037
"fast-glob@3.3.3": {
907
1038
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
908
1039
"dependencies": [
···
913
1044
"micromatch"
914
1045
]
915
1046
},
1047
+
"fast-redact@3.5.0": {
1048
+
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="
1049
+
},
916
1050
"fastq@1.19.1": {
917
1051
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
918
1052
"dependencies": [
···
928
1062
"to-regex-range"
929
1063
]
930
1064
},
1065
+
"follow-redirects@1.15.9": {
1066
+
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
1067
+
},
931
1068
"foreground-child@3.3.1": {
932
1069
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
933
1070
"dependencies": [
···
935
1072
"signal-exit"
936
1073
]
937
1074
},
1075
+
"form-data@4.0.3": {
1076
+
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
1077
+
"dependencies": [
1078
+
"asynckit",
1079
+
"combined-stream",
1080
+
"es-set-tostringtag",
1081
+
"hasown",
1082
+
"mime-types"
1083
+
]
1084
+
},
938
1085
"fraction.js@4.3.7": {
939
1086
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="
940
1087
},
···
946
1093
"function-bind@1.1.2": {
947
1094
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
948
1095
},
1096
+
"get-intrinsic@1.3.0": {
1097
+
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
1098
+
"dependencies": [
1099
+
"call-bind-apply-helpers",
1100
+
"es-define-property",
1101
+
"es-errors",
1102
+
"es-object-atoms",
1103
+
"function-bind",
1104
+
"get-proto",
1105
+
"gopd",
1106
+
"has-symbols",
1107
+
"hasown",
1108
+
"math-intrinsics"
1109
+
]
1110
+
},
1111
+
"get-proto@1.0.1": {
1112
+
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
1113
+
"dependencies": [
1114
+
"dunder-proto",
1115
+
"es-object-atoms"
1116
+
]
1117
+
},
949
1118
"glob-parent@5.1.2": {
950
1119
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
951
1120
"dependencies": [
···
970
1139
],
971
1140
"bin": true
972
1141
},
1142
+
"gopd@1.2.0": {
1143
+
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
1144
+
},
973
1145
"graphemer@1.4.0": {
974
1146
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
975
1147
},
1148
+
"has-symbols@1.1.0": {
1149
+
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
1150
+
},
1151
+
"has-tostringtag@1.0.2": {
1152
+
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
1153
+
"dependencies": [
1154
+
"has-symbols"
1155
+
]
1156
+
},
976
1157
"hasown@2.0.2": {
977
1158
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
978
1159
"dependencies": [
979
1160
"function-bind"
980
1161
]
1162
+
},
1163
+
"ieee754@1.2.1": {
1164
+
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
981
1165
},
982
1166
"ipaddr.js@2.2.0": {
983
1167
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="
···
1063
1247
"preact@10.26.6"
1064
1248
]
1065
1249
},
1250
+
"math-intrinsics@1.1.0": {
1251
+
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
1252
+
},
1066
1253
"mdn-data@2.0.28": {
1067
1254
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="
1068
1255
},
···
1079
1266
"picomatch"
1080
1267
]
1081
1268
},
1269
+
"mime-db@1.52.0": {
1270
+
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
1271
+
},
1272
+
"mime-types@2.1.35": {
1273
+
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1274
+
"dependencies": [
1275
+
"mime-db"
1276
+
]
1277
+
},
1082
1278
"minimatch@9.0.5": {
1083
1279
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
1084
1280
"dependencies": [
···
1127
1323
"object-hash@3.0.0": {
1128
1324
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="
1129
1325
},
1326
+
"on-exit-leak-free@2.1.2": {
1327
+
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="
1328
+
},
1329
+
"one-webcrypto@1.0.3": {
1330
+
"integrity": "sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q=="
1331
+
},
1130
1332
"package-json-from-dist@1.0.1": {
1131
1333
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
1132
1334
},
···
1152
1354
"pify@2.3.0": {
1153
1355
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="
1154
1356
},
1357
+
"pino-abstract-transport@1.2.0": {
1358
+
"integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==",
1359
+
"dependencies": [
1360
+
"readable-stream",
1361
+
"split2"
1362
+
]
1363
+
},
1364
+
"pino-std-serializers@6.2.2": {
1365
+
"integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="
1366
+
},
1367
+
"pino@8.21.0": {
1368
+
"integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==",
1369
+
"dependencies": [
1370
+
"atomic-sleep",
1371
+
"fast-redact",
1372
+
"on-exit-leak-free",
1373
+
"pino-abstract-transport",
1374
+
"pino-std-serializers",
1375
+
"process-warning",
1376
+
"quick-format-unescaped",
1377
+
"real-require",
1378
+
"safe-stable-stringify",
1379
+
"sonic-boom",
1380
+
"thread-stream"
1381
+
],
1382
+
"bin": true
1383
+
},
1155
1384
"pirates@4.0.7": {
1156
1385
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="
1157
1386
},
···
1451
1680
"preact@10.26.7": {
1452
1681
"integrity": "sha512-43xS+QYc1X1IPbw03faSgY6I6OYWcLrJRv3hU0+qMOfh/XCHcP0MX2CVjNARYR2cC/guu975sta4OcjlczxD7g=="
1453
1682
},
1683
+
"process-warning@3.0.0": {
1684
+
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="
1685
+
},
1686
+
"process@0.11.10": {
1687
+
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
1688
+
},
1689
+
"proxy-from-env@1.1.0": {
1690
+
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
1691
+
},
1454
1692
"psl@1.15.0": {
1455
1693
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
1456
1694
"dependencies": [
···
1463
1701
"queue-microtask@1.2.3": {
1464
1702
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
1465
1703
},
1704
+
"quick-format-unescaped@4.0.4": {
1705
+
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
1706
+
},
1466
1707
"read-cache@1.0.0": {
1467
1708
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
1468
1709
"dependencies": [
1469
1710
"pify"
1470
1711
]
1471
1712
},
1713
+
"readable-stream@4.7.0": {
1714
+
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
1715
+
"dependencies": [
1716
+
"abort-controller",
1717
+
"buffer",
1718
+
"events",
1719
+
"process",
1720
+
"string_decoder"
1721
+
]
1722
+
},
1472
1723
"readdirp@3.6.0": {
1473
1724
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1474
1725
"dependencies": [
1475
1726
"picomatch"
1476
1727
]
1728
+
},
1729
+
"real-require@0.2.0": {
1730
+
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
1477
1731
},
1478
1732
"resolve@1.22.10": {
1479
1733
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
···
1493
1747
"queue-microtask"
1494
1748
]
1495
1749
},
1750
+
"safe-buffer@5.2.1": {
1751
+
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
1752
+
},
1753
+
"safe-stable-stringify@2.5.0": {
1754
+
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
1755
+
},
1496
1756
"shebang-command@2.0.0": {
1497
1757
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
1498
1758
"dependencies": [
···
1504
1764
},
1505
1765
"signal-exit@4.1.0": {
1506
1766
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
1767
+
},
1768
+
"sonic-boom@3.8.1": {
1769
+
"integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==",
1770
+
"dependencies": [
1771
+
"atomic-sleep"
1772
+
]
1507
1773
},
1508
1774
"source-map-js@1.2.1": {
1509
1775
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
1510
1776
},
1777
+
"split2@4.2.0": {
1778
+
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
1779
+
},
1511
1780
"string-width@4.2.3": {
1512
1781
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
1513
1782
"dependencies": [
···
1522
1791
"eastasianwidth",
1523
1792
"emoji-regex@9.2.2",
1524
1793
"strip-ansi@7.1.0"
1794
+
]
1795
+
},
1796
+
"string_decoder@1.3.0": {
1797
+
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
1798
+
"dependencies": [
1799
+
"safe-buffer"
1525
1800
]
1526
1801
},
1527
1802
"strip-ansi@6.0.1": {
···
1613
1888
"any-promise"
1614
1889
]
1615
1890
},
1891
+
"thread-stream@2.7.0": {
1892
+
"integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==",
1893
+
"dependencies": [
1894
+
"real-require"
1895
+
]
1896
+
},
1616
1897
"tlds@1.258.0": {
1617
1898
"integrity": "sha512-XGhStWuOlBA5D8QnyN2xtgB2cUOdJ3ztisne1DYVWMcVH29qh8eQIpRmP3HnuJLdgyzG0HpdGzRMu1lm/Oictw==",
1618
1899
"bin": true
···
1709
1990
"jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7",
1710
1991
"jsr:@knotbin/posthog-fresh@~0.1.3",
1711
1992
"npm:@atproto/api@~0.15.6",
1993
+
"npm:@atproto/crypto@~0.4.4",
1994
+
"npm:@did-plc/lib@^0.0.4",
1712
1995
"npm:@preact/signals@^2.0.4",
1713
1996
"npm:posthog-js@1.120.0",
1714
1997
"npm:preact@^10.26.6",
+5
islands/CredLogin.tsx
+5
islands/CredLogin.tsx
···
1
1
import { useState } from "preact/hooks";
2
2
import { JSX } from "preact";
3
3
4
+
/**
5
+
* The credential login form.
6
+
* @returns The credential login form
7
+
* @component
8
+
*/
4
9
export default function CredLogin() {
5
10
const [handle, setHandle] = useState("");
6
11
const [password, setPassword] = useState("");
+1136
islands/DidPlcProgress.tsx
+1136
islands/DidPlcProgress.tsx
···
1
+
import { useState, useEffect } from "preact/hooks";
2
+
import { Link } from "../components/Link.tsx";
3
+
4
+
interface PlcUpdateStep {
5
+
name: string;
6
+
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
7
+
error?: string;
8
+
}
9
+
10
+
// Content chunks for the description
11
+
const contentChunks = [
12
+
{
13
+
title: "Welcome to Key Management",
14
+
subtitle: "BOARDING PASS - SECTION A",
15
+
content: (
16
+
<>
17
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
18
+
GATE: KEY-01 โข SEAT: DID-1A
19
+
</div>
20
+
<p class="text-slate-700 dark:text-slate-300 mb-4">
21
+
This tool helps you add a new rotation key to your{" "}
22
+
<Link
23
+
href="https://web.plc.directory/"
24
+
isExternal
25
+
class="text-blue-600 dark:text-blue-400"
26
+
>
27
+
PLC (Public Ledger of Credentials)
28
+
</Link>
29
+
. Having control of a rotation key gives you sovereignty over your DID
30
+
(Decentralized Identifier).
31
+
</p>
32
+
</>
33
+
),
34
+
},
35
+
{
36
+
title: "Key Benefits",
37
+
subtitle: "BOARDING PASS - SECTION B",
38
+
content: (
39
+
<>
40
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
41
+
GATE: KEY-02 โข SEAT: DID-1B
42
+
</div>
43
+
<div class="space-y-4">
44
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
45
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2">
46
+
PROVIDER MOBILITY โ๏ธ
47
+
</h4>
48
+
<p class="text-slate-700 dark:text-slate-300">
49
+
Change your PDS without losing your identity, protecting you if
50
+
your provider becomes hostile.
51
+
</p>
52
+
</div>
53
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
54
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2">
55
+
IDENTITY CONTROL โจ
56
+
</h4>
57
+
<p class="text-slate-700 dark:text-slate-300">
58
+
Modify your DID document independently of your provider.
59
+
</p>
60
+
</div>
61
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
62
+
<p class="text-slate-700 dark:text-slate-300">
63
+
๐ก It's good practice to have a rotation key so you can move to a
64
+
different provider if you need to.
65
+
</p>
66
+
</div>
67
+
</div>
68
+
</>
69
+
),
70
+
},
71
+
{
72
+
title: "โ ๏ธ CRITICAL SECURITY WARNING",
73
+
subtitle: "BOARDING PASS - SECTION C",
74
+
content: (
75
+
<>
76
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
77
+
GATE: KEY-03 โข SEAT: DID-1C
78
+
</div>
79
+
<div class="p-4 bg-red-50 dark:bg-red-900 rounded-lg border-2 border-red-500 dark:border-red-700 mb-4">
80
+
<div class="flex items-center mb-3">
81
+
<span class="text-2xl mr-2">โ ๏ธ</span>
82
+
<h4 class="font-mono font-bold text-red-700 dark:text-red-400 text-lg">
83
+
NON-REVOCABLE KEY WARNING
84
+
</h4>
85
+
</div>
86
+
<div class="space-y-3 text-red-700 dark:text-red-300">
87
+
<p class="font-bold">
88
+
This rotation key CANNOT BE DISABLED OR DELETED once added:
89
+
</p>
90
+
<ul class="list-disc pl-5 space-y-2">
91
+
<li>
92
+
If compromised, the attacker can take complete control of your
93
+
account and identity
94
+
</li>
95
+
<li>
96
+
Malicious actors with this key have COMPLETE CONTROL of your
97
+
account and identity
98
+
</li>
99
+
<li>
100
+
Store securely, like a password (e.g. <strong>DO NOT</strong>{" "}
101
+
keep it in Notes or any easily accessible app on an unlocked
102
+
device).
103
+
</li>
104
+
</ul>
105
+
</div>
106
+
</div>
107
+
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
108
+
<p class="text-slate-700 dark:text-slate-300">
109
+
๐ก We recommend adding a custom rotation key but recommend{" "}
110
+
<strong class="italic">against</strong> having more than one custom
111
+
rotation key, as more than one increases risk.
112
+
</p>
113
+
</div>
114
+
</>
115
+
),
116
+
},
117
+
{
118
+
title: "Technical Overview",
119
+
subtitle: "BOARDING PASS - SECTION C",
120
+
content: (
121
+
<>
122
+
<div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4">
123
+
GATE: KEY-03 โข SEAT: DID-1C
124
+
</div>
125
+
<div class="p-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
126
+
<div class="flex items-center mb-3">
127
+
<span class="text-lg mr-2">๐</span>
128
+
<h4 class="font-mono font-bold text-amber-500 dark:text-amber-400">
129
+
TECHNICAL DETAILS
130
+
</h4>
131
+
</div>
132
+
<p class="text-slate-700 dark:text-slate-300">
133
+
The rotation key is a did:key that will be added to your PLC
134
+
document's rotationKeys array. This process uses the AT Protocol's
135
+
PLC operations to update your DID document.
136
+
<Link
137
+
href="https://web.plc.directory/"
138
+
class="block ml-1 text-blue-600 dark:text-blue-400"
139
+
isExternal
140
+
>
141
+
Learn more about did:plc
142
+
</Link>
143
+
</p>
144
+
</div>
145
+
</>
146
+
),
147
+
},
148
+
];
149
+
150
+
export default function PlcUpdateProgress() {
151
+
const [hasStarted, setHasStarted] = useState(false);
152
+
const [currentChunkIndex, setCurrentChunkIndex] = useState(0);
153
+
const [steps, setSteps] = useState<PlcUpdateStep[]>([
154
+
{ name: "Generate Rotation Key", status: "pending" },
155
+
{ name: "Start PLC update", status: "pending" },
156
+
{ name: "Complete PLC update", status: "pending" },
157
+
]);
158
+
const [generatedKey, setGeneratedKey] = useState<string>("");
159
+
const [keyJson, setKeyJson] = useState<any>(null);
160
+
const [emailToken, setEmailToken] = useState<string>("");
161
+
const [updateResult, setUpdateResult] = useState<string>("");
162
+
const [showDownload, setShowDownload] = useState(false);
163
+
const [showKeyInfo, setShowKeyInfo] = useState(false);
164
+
const [hasDownloadedKey, setHasDownloadedKey] = useState(false);
165
+
const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null);
166
+
167
+
const updateStepStatus = (
168
+
index: number,
169
+
status: PlcUpdateStep["status"],
170
+
error?: string
171
+
) => {
172
+
console.log(
173
+
`Updating step ${index} to ${status}${
174
+
error ? ` with error: ${error}` : ""
175
+
}`
176
+
);
177
+
setSteps((prevSteps) =>
178
+
prevSteps.map((step, i) =>
179
+
i === index
180
+
? { ...step, status, error }
181
+
: i > index
182
+
? { ...step, status: "pending", error: undefined }
183
+
: step
184
+
)
185
+
);
186
+
};
187
+
188
+
const handleStart = () => {
189
+
setHasStarted(true);
190
+
// Automatically start the first step
191
+
setTimeout(() => {
192
+
handleGenerateKey();
193
+
}, 100);
194
+
};
195
+
196
+
const getStepDisplayName = (step: PlcUpdateStep, index: number) => {
197
+
if (step.status === "completed") {
198
+
switch (index) {
199
+
case 0:
200
+
return "Rotation Key Generated";
201
+
case 1:
202
+
return "PLC Operation Requested";
203
+
case 2:
204
+
return "PLC Update Completed";
205
+
}
206
+
}
207
+
208
+
if (step.status === "in-progress") {
209
+
switch (index) {
210
+
case 0:
211
+
return "Generating Rotation Key...";
212
+
case 1:
213
+
return "Requesting PLC Operation Token...";
214
+
case 2:
215
+
return step.name ===
216
+
"Enter the code sent to your email to complete PLC update"
217
+
? step.name
218
+
: "Completing PLC Update...";
219
+
}
220
+
}
221
+
222
+
if (step.status === "verifying") {
223
+
switch (index) {
224
+
case 0:
225
+
return "Verifying Rotation Key Generation...";
226
+
case 1:
227
+
return "Verifying PLC Operation Token Request...";
228
+
case 2:
229
+
return "Verifying PLC Update Completion...";
230
+
}
231
+
}
232
+
233
+
return step.name;
234
+
};
235
+
236
+
const handleStartPlcUpdate = async (keyToUse?: string) => {
237
+
const key = keyToUse || generatedKey;
238
+
239
+
// Debug logging
240
+
console.log("=== PLC Update Debug ===");
241
+
console.log("Current state:", {
242
+
keyToUse,
243
+
generatedKey,
244
+
key,
245
+
hasKeyJson: !!keyJson,
246
+
keyJsonId: keyJson?.publicKeyDid,
247
+
hasDownloadedKey,
248
+
downloadedKeyId,
249
+
steps: steps.map((s) => ({ name: s.name, status: s.status })),
250
+
});
251
+
252
+
if (!key) {
253
+
console.log("No key generated yet");
254
+
updateStepStatus(1, "error", "No key generated yet");
255
+
return;
256
+
}
257
+
258
+
if (!keyJson || keyJson.publicKeyDid !== key) {
259
+
console.log("Key mismatch or missing:", {
260
+
hasKeyJson: !!keyJson,
261
+
keyJsonId: keyJson?.publicKeyDid,
262
+
expectedKey: key,
263
+
});
264
+
updateStepStatus(
265
+
1,
266
+
"error",
267
+
"Please ensure you have the correct key loaded"
268
+
);
269
+
return;
270
+
}
271
+
272
+
updateStepStatus(1, "in-progress");
273
+
try {
274
+
// First request the token
275
+
console.log("Requesting PLC token...");
276
+
const tokenRes = await fetch("/api/plc/token", {
277
+
method: "GET",
278
+
});
279
+
const tokenText = await tokenRes.text();
280
+
console.log("Token response:", tokenText);
281
+
282
+
if (!tokenRes.ok) {
283
+
try {
284
+
const json = JSON.parse(tokenText);
285
+
throw new Error(json.message || "Failed to request PLC token");
286
+
} catch {
287
+
throw new Error(tokenText || "Failed to request PLC token");
288
+
}
289
+
}
290
+
291
+
let data;
292
+
try {
293
+
data = JSON.parse(tokenText);
294
+
if (!data.success) {
295
+
throw new Error(data.message || "Failed to request token");
296
+
}
297
+
} catch (error) {
298
+
throw new Error("Invalid response from server");
299
+
}
300
+
301
+
console.log("Token request successful, updating UI...");
302
+
// Update step name to prompt for token
303
+
setSteps((prevSteps) =>
304
+
prevSteps.map((step, i) =>
305
+
i === 1
306
+
? {
307
+
...step,
308
+
name: "Enter the code sent to your email to complete PLC update",
309
+
status: "in-progress",
310
+
}
311
+
: step
312
+
)
313
+
);
314
+
} catch (error) {
315
+
console.error("Token request failed:", error);
316
+
updateStepStatus(
317
+
1,
318
+
"error",
319
+
error instanceof Error ? error.message : String(error)
320
+
);
321
+
}
322
+
};
323
+
324
+
const handleTokenSubmit = async () => {
325
+
console.log("=== Token Submit Debug ===");
326
+
console.log("Current state:", {
327
+
emailToken,
328
+
generatedKey,
329
+
keyJsonId: keyJson?.publicKeyDid,
330
+
steps: steps.map((s) => ({ name: s.name, status: s.status })),
331
+
});
332
+
333
+
if (!emailToken) {
334
+
console.log("No token provided");
335
+
updateStepStatus(1, "error", "Please enter the email token");
336
+
return;
337
+
}
338
+
339
+
if (!keyJson || !keyJson.publicKeyDid) {
340
+
console.log("Missing key data");
341
+
updateStepStatus(1, "error", "Key data is missing, please try again");
342
+
return;
343
+
}
344
+
345
+
// Prevent duplicate submissions
346
+
if (steps[1].status === "completed" || steps[2].status === "completed") {
347
+
console.log("Update already completed, preventing duplicate submission");
348
+
return;
349
+
}
350
+
351
+
updateStepStatus(1, "completed");
352
+
try {
353
+
updateStepStatus(2, "in-progress");
354
+
console.log("Submitting update request with token...");
355
+
// Send the update request with both key and token
356
+
const res = await fetch("/api/plc/update", {
357
+
method: "POST",
358
+
headers: { "Content-Type": "application/json" },
359
+
body: JSON.stringify({
360
+
key: keyJson.publicKeyDid,
361
+
token: emailToken,
362
+
}),
363
+
});
364
+
const text = await res.text();
365
+
console.log("Update response:", text);
366
+
367
+
let data;
368
+
try {
369
+
data = JSON.parse(text);
370
+
} catch {
371
+
throw new Error("Invalid response from server");
372
+
}
373
+
374
+
// Check for error responses
375
+
if (!res.ok || !data.success) {
376
+
const errorMessage = data.message || "Failed to complete PLC update";
377
+
console.error("Update failed:", errorMessage);
378
+
throw new Error(errorMessage);
379
+
}
380
+
381
+
// Only proceed if we have a successful response
382
+
console.log("Update completed successfully!");
383
+
setUpdateResult("PLC update completed successfully!");
384
+
385
+
// Add a delay before marking steps as completed for better UX
386
+
updateStepStatus(2, "verifying");
387
+
388
+
const verifyRes = await fetch("/api/plc/verify", {
389
+
method: "POST",
390
+
headers: { "Content-Type": "application/json" },
391
+
body: JSON.stringify({
392
+
key: keyJson.publicKeyDid,
393
+
}),
394
+
});
395
+
396
+
const verifyText = await verifyRes.text();
397
+
console.log("Verification response:", verifyText);
398
+
399
+
let verifyData;
400
+
try {
401
+
verifyData = JSON.parse(verifyText);
402
+
} catch {
403
+
throw new Error("Invalid verification response from server");
404
+
}
405
+
406
+
if (!verifyRes.ok || !verifyData.success) {
407
+
const errorMessage =
408
+
verifyData.message || "Failed to verify PLC update";
409
+
console.error("Verification failed:", errorMessage);
410
+
throw new Error(errorMessage);
411
+
}
412
+
413
+
console.log("Verification successful, marking steps as completed");
414
+
updateStepStatus(2, "completed");
415
+
} catch (error) {
416
+
console.error("Update failed:", error);
417
+
// Reset the steps to error state
418
+
updateStepStatus(
419
+
1,
420
+
"error",
421
+
error instanceof Error ? error.message : String(error)
422
+
);
423
+
updateStepStatus(2, "pending"); // Reset the final step
424
+
setUpdateResult(error instanceof Error ? error.message : String(error));
425
+
426
+
// If token is invalid, we should clear it so user can try again
427
+
if (
428
+
error instanceof Error &&
429
+
error.message.toLowerCase().includes("token is invalid")
430
+
) {
431
+
setEmailToken("");
432
+
}
433
+
}
434
+
};
435
+
436
+
const handleCompletePlcUpdate = async () => {
437
+
// This function is no longer needed as we handle everything in handleTokenSubmit
438
+
return;
439
+
};
440
+
441
+
const handleDownload = () => {
442
+
console.log("=== Download Debug ===");
443
+
console.log("Download started with:", {
444
+
hasKeyJson: !!keyJson,
445
+
keyJsonId: keyJson?.publicKeyDid,
446
+
});
447
+
448
+
if (!keyJson) {
449
+
console.error("No key JSON to download");
450
+
return;
451
+
}
452
+
453
+
try {
454
+
const jsonString = JSON.stringify(keyJson, null, 2);
455
+
const blob = new Blob([jsonString], {
456
+
type: "application/json",
457
+
});
458
+
const url = URL.createObjectURL(blob);
459
+
const a = document.createElement("a");
460
+
a.href = url;
461
+
a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`;
462
+
a.style.display = "none";
463
+
document.body.appendChild(a);
464
+
a.click();
465
+
document.body.removeChild(a);
466
+
URL.revokeObjectURL(url);
467
+
468
+
console.log("Download completed, proceeding to next step...");
469
+
setHasDownloadedKey(true);
470
+
setDownloadedKeyId(keyJson.publicKeyDid);
471
+
472
+
// Automatically proceed to the next step after successful download
473
+
setTimeout(() => {
474
+
console.log("Auto-proceeding with key:", keyJson.publicKeyDid);
475
+
handleStartPlcUpdate(keyJson.publicKeyDid);
476
+
}, 1000);
477
+
} catch (error) {
478
+
console.error("Download failed:", error);
479
+
}
480
+
};
481
+
482
+
const handleGenerateKey = async () => {
483
+
console.log("=== Generate Key Debug ===");
484
+
updateStepStatus(0, "in-progress");
485
+
setShowDownload(false);
486
+
setKeyJson(null);
487
+
setGeneratedKey("");
488
+
setHasDownloadedKey(false);
489
+
setDownloadedKeyId(null);
490
+
491
+
try {
492
+
console.log("Requesting new key...");
493
+
const res = await fetch("/api/plc/keys");
494
+
const text = await res.text();
495
+
console.log("Key generation response:", text);
496
+
497
+
if (!res.ok) {
498
+
try {
499
+
const json = JSON.parse(text);
500
+
throw new Error(json.message || "Failed to generate key");
501
+
} catch {
502
+
throw new Error(text || "Failed to generate key");
503
+
}
504
+
}
505
+
506
+
let data;
507
+
try {
508
+
data = JSON.parse(text);
509
+
} catch {
510
+
throw new Error("Invalid response from /api/plc/keys");
511
+
}
512
+
513
+
if (!data.publicKeyDid || !data.privateKeyHex) {
514
+
throw new Error("Key generation failed: missing key data");
515
+
}
516
+
517
+
console.log("Key generated successfully:", {
518
+
keyId: data.publicKeyDid,
519
+
});
520
+
521
+
setGeneratedKey(data.publicKeyDid);
522
+
setKeyJson(data);
523
+
setShowDownload(true);
524
+
updateStepStatus(0, "completed");
525
+
} catch (error) {
526
+
console.error("Key generation failed:", error);
527
+
updateStepStatus(
528
+
0,
529
+
"error",
530
+
error instanceof Error ? error.message : String(error)
531
+
);
532
+
}
533
+
};
534
+
535
+
const getStepIcon = (status: PlcUpdateStep["status"]) => {
536
+
switch (status) {
537
+
case "pending":
538
+
return (
539
+
<div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
540
+
<div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
541
+
</div>
542
+
);
543
+
case "in-progress":
544
+
return (
545
+
<div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
546
+
<div class="w-3 h-3 rounded-full bg-blue-500" />
547
+
</div>
548
+
);
549
+
case "verifying":
550
+
return (
551
+
<div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
552
+
<div class="w-3 h-3 rounded-full bg-yellow-500" />
553
+
</div>
554
+
);
555
+
case "completed":
556
+
return (
557
+
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
558
+
<svg
559
+
class="w-5 h-5 text-white"
560
+
fill="none"
561
+
stroke="currentColor"
562
+
viewBox="0 0 24 24"
563
+
>
564
+
<path
565
+
stroke-linecap="round"
566
+
stroke-linejoin="round"
567
+
stroke-width="2"
568
+
d="M5 13l4 4L19 7"
569
+
/>
570
+
</svg>
571
+
</div>
572
+
);
573
+
case "error":
574
+
return (
575
+
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
576
+
<svg
577
+
class="w-5 h-5 text-white"
578
+
fill="none"
579
+
stroke="currentColor"
580
+
viewBox="0 0 24 24"
581
+
>
582
+
<path
583
+
stroke-linecap="round"
584
+
stroke-linejoin="round"
585
+
stroke-width="2"
586
+
d="M6 18L18 6M6 6l12 12"
587
+
/>
588
+
</svg>
589
+
</div>
590
+
);
591
+
}
592
+
};
593
+
594
+
const getStepClasses = (status: PlcUpdateStep["status"]) => {
595
+
const baseClasses =
596
+
"flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
597
+
switch (status) {
598
+
case "pending":
599
+
return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
600
+
case "in-progress":
601
+
return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
602
+
case "verifying":
603
+
return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
604
+
case "completed":
605
+
return `${baseClasses} bg-green-50 dark:bg-green-900`;
606
+
case "error":
607
+
return `${baseClasses} bg-red-50 dark:bg-red-900`;
608
+
}
609
+
};
610
+
611
+
const requestNewToken = async () => {
612
+
try {
613
+
console.log("Requesting new token...");
614
+
const res = await fetch("/api/plc/token", {
615
+
method: "GET",
616
+
});
617
+
const text = await res.text();
618
+
console.log("Token request response:", text);
619
+
620
+
if (!res.ok) {
621
+
throw new Error(text || "Failed to request new token");
622
+
}
623
+
624
+
let data;
625
+
try {
626
+
data = JSON.parse(text);
627
+
if (!data.success) {
628
+
throw new Error(data.message || "Failed to request token");
629
+
}
630
+
} catch {
631
+
throw new Error("Invalid response from server");
632
+
}
633
+
634
+
// Clear any existing error and token
635
+
setEmailToken("");
636
+
updateStepStatus(1, "in-progress");
637
+
updateStepStatus(2, "pending");
638
+
} catch (error) {
639
+
console.error("Failed to request new token:", error);
640
+
updateStepStatus(
641
+
1,
642
+
"error",
643
+
error instanceof Error ? error.message : String(error)
644
+
);
645
+
}
646
+
};
647
+
648
+
if (!hasStarted) {
649
+
return (
650
+
<div class="space-y-6">
651
+
<div class="ticket bg-white dark:bg-slate-800 p-6 relative">
652
+
<div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-2">
653
+
{contentChunks[currentChunkIndex].subtitle}
654
+
</div>
655
+
656
+
<div class="flex justify-between items-start mb-4">
657
+
<h3 class="text-2xl font-mono text-slate-800 dark:text-slate-200">
658
+
{contentChunks[currentChunkIndex].title}
659
+
</h3>
660
+
</div>
661
+
662
+
{/* Main Description */}
663
+
<div class="mb-6">{contentChunks[currentChunkIndex].content}</div>
664
+
665
+
{/* Navigation */}
666
+
<div class="mt-8 border-t border-dashed border-slate-200 dark:border-slate-700 pt-4">
667
+
<div class="flex justify-between items-center">
668
+
<button
669
+
onClick={() =>
670
+
setCurrentChunkIndex((prev) => Math.max(0, prev - 1))
671
+
}
672
+
class={`px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2 ${
673
+
currentChunkIndex === 0 ? "invisible" : ""
674
+
}`}
675
+
>
676
+
<svg
677
+
class="w-5 h-5 rotate-180"
678
+
fill="none"
679
+
stroke="currentColor"
680
+
viewBox="0 0 24 24"
681
+
>
682
+
<path
683
+
stroke-linecap="round"
684
+
stroke-linejoin="round"
685
+
stroke-width="2"
686
+
d="M9 5l7 7-7 7"
687
+
/>
688
+
</svg>
689
+
<span>Previous Gate</span>
690
+
</button>
691
+
692
+
{currentChunkIndex === contentChunks.length - 1 ? (
693
+
<button
694
+
onClick={handleStart}
695
+
class="px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white font-mono rounded-md transition-colors duration-200 flex items-center space-x-2"
696
+
>
697
+
<span>Begin Key Generation</span>
698
+
<svg
699
+
class="w-5 h-5"
700
+
fill="none"
701
+
stroke="currentColor"
702
+
viewBox="0 0 24 24"
703
+
>
704
+
<path
705
+
stroke-linecap="round"
706
+
stroke-linejoin="round"
707
+
stroke-width="2"
708
+
d="M9 5l7 7-7 7"
709
+
/>
710
+
</svg>
711
+
</button>
712
+
) : (
713
+
<button
714
+
onClick={() =>
715
+
setCurrentChunkIndex((prev) =>
716
+
Math.min(contentChunks.length - 1, prev + 1)
717
+
)
718
+
}
719
+
class="px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2"
720
+
>
721
+
<span>Next Gate</span>
722
+
<svg
723
+
class="w-5 h-5"
724
+
fill="none"
725
+
stroke="currentColor"
726
+
viewBox="0 0 24 24"
727
+
>
728
+
<path
729
+
stroke-linecap="round"
730
+
stroke-linejoin="round"
731
+
stroke-width="2"
732
+
d="M9 5l7 7-7 7"
733
+
/>
734
+
</svg>
735
+
</button>
736
+
)}
737
+
</div>
738
+
739
+
{/* Progress Dots */}
740
+
<div class="flex justify-center space-x-3 mt-4">
741
+
{contentChunks.map((_, index) => (
742
+
<div
743
+
key={index}
744
+
class={`h-1.5 w-8 rounded-full transition-colors duration-200 ${
745
+
index === currentChunkIndex
746
+
? "bg-amber-500"
747
+
: "bg-slate-200 dark:bg-slate-700"
748
+
}`}
749
+
/>
750
+
))}
751
+
</div>
752
+
</div>
753
+
</div>
754
+
</div>
755
+
);
756
+
}
757
+
758
+
return (
759
+
<div class="space-y-8">
760
+
{/* Progress Steps */}
761
+
<div class="space-y-4">
762
+
<div class="flex items-center justify-between">
763
+
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
764
+
Key Generation Progress
765
+
</h3>
766
+
{/* Add a help tooltip */}
767
+
<div class="relative group">
768
+
<button class="text-gray-400 hover:text-gray-500">
769
+
<svg
770
+
class="w-5 h-5"
771
+
fill="none"
772
+
stroke="currentColor"
773
+
viewBox="0 0 24 24"
774
+
>
775
+
<path
776
+
stroke-linecap="round"
777
+
stroke-linejoin="round"
778
+
stroke-width="2"
779
+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
780
+
/>
781
+
</svg>
782
+
</button>
783
+
<div class="absolute right-0 w-64 p-2 mt-2 space-y-1 text-sm bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 hidden group-hover:block z-10">
784
+
<p class="text-gray-600 dark:text-gray-400">
785
+
Follow these steps to securely add a new rotation key to your
786
+
PLC record. Each step requires completion before proceeding.
787
+
</p>
788
+
</div>
789
+
</div>
790
+
</div>
791
+
792
+
{/* Steps with enhanced visual hierarchy */}
793
+
{steps.map((step, index) => (
794
+
<div
795
+
key={step.name}
796
+
class={`${getStepClasses(step.status)} ${
797
+
step.status === "in-progress"
798
+
? "ring-2 ring-blue-500 ring-opacity-50"
799
+
: ""
800
+
}`}
801
+
>
802
+
<div class="flex-shrink-0">{getStepIcon(step.status)}</div>
803
+
<div class="flex-1 min-w-0">
804
+
<div class="flex items-center justify-between">
805
+
<p
806
+
class={`font-medium ${
807
+
step.status === "error"
808
+
? "text-red-900 dark:text-red-200"
809
+
: step.status === "completed"
810
+
? "text-green-900 dark:text-green-200"
811
+
: step.status === "in-progress"
812
+
? "text-blue-900 dark:text-blue-200"
813
+
: "text-gray-900 dark:text-gray-200"
814
+
}`}
815
+
>
816
+
{getStepDisplayName(step, index)}
817
+
</p>
818
+
{/* Add step number */}
819
+
<span class="text-sm text-gray-500 dark:text-gray-400">
820
+
Step {index + 1} of {steps.length}
821
+
</span>
822
+
</div>
823
+
824
+
{step.error && (
825
+
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md">
826
+
<p class="text-sm text-red-600 dark:text-red-400 flex items-center">
827
+
<svg
828
+
class="w-4 h-4 mr-1"
829
+
fill="none"
830
+
stroke="currentColor"
831
+
viewBox="0 0 24 24"
832
+
>
833
+
<path
834
+
stroke-linecap="round"
835
+
stroke-linejoin="round"
836
+
stroke-width="2"
837
+
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
838
+
/>
839
+
</svg>
840
+
{(() => {
841
+
try {
842
+
const err = JSON.parse(step.error);
843
+
return err.message || step.error;
844
+
} catch {
845
+
return step.error;
846
+
}
847
+
})()}
848
+
</p>
849
+
</div>
850
+
)}
851
+
852
+
{/* Key Download Warning */}
853
+
{index === 0 &&
854
+
step.status === "completed" &&
855
+
!hasDownloadedKey && (
856
+
<div class="mt-4 space-y-4">
857
+
<div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
858
+
<div class="flex items-start">
859
+
<div class="flex-shrink-0">
860
+
<svg
861
+
class="h-5 w-5 text-yellow-400"
862
+
viewBox="0 0 20 20"
863
+
fill="currentColor"
864
+
>
865
+
<path
866
+
fill-rule="evenodd"
867
+
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
868
+
clip-rule="evenodd"
869
+
/>
870
+
</svg>
871
+
</div>
872
+
<div class="ml-3">
873
+
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
874
+
Critical Security Step
875
+
</h3>
876
+
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
877
+
<p class="mb-2">
878
+
Your rotation key grants control over your
879
+
identity:
880
+
</p>
881
+
<ul class="list-disc pl-5 space-y-2">
882
+
<li>
883
+
<strong>Store Securely:</strong> Use a password
884
+
manager
885
+
</li>
886
+
<li>
887
+
<strong>Keep Private:</strong> Never share with
888
+
anyone
889
+
</li>
890
+
<li>
891
+
<strong>Backup:</strong> Keep a secure backup
892
+
copy
893
+
</li>
894
+
<li>
895
+
<strong>Required:</strong> Needed for future DID
896
+
modifications
897
+
</li>
898
+
</ul>
899
+
</div>
900
+
</div>
901
+
</div>
902
+
</div>
903
+
904
+
<div class="flex items-center justify-between">
905
+
<button
906
+
onClick={handleDownload}
907
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
908
+
>
909
+
<svg
910
+
class="w-5 h-5"
911
+
fill="none"
912
+
stroke="currentColor"
913
+
viewBox="0 0 24 24"
914
+
>
915
+
<path
916
+
stroke-linecap="round"
917
+
stroke-linejoin="round"
918
+
stroke-width="2"
919
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
920
+
/>
921
+
</svg>
922
+
<span>Download Key</span>
923
+
</button>
924
+
925
+
<div class="flex items-center text-sm text-red-600 dark:text-red-400">
926
+
<svg
927
+
class="w-4 h-4 mr-1"
928
+
fill="none"
929
+
stroke="currentColor"
930
+
viewBox="0 0 24 24"
931
+
>
932
+
<path
933
+
stroke-linecap="round"
934
+
stroke-linejoin="round"
935
+
stroke-width="2"
936
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
937
+
/>
938
+
</svg>
939
+
Download required to proceed
940
+
</div>
941
+
</div>
942
+
</div>
943
+
)}
944
+
945
+
{/* Email Code Input */}
946
+
{index === 1 &&
947
+
(step.status === "in-progress" ||
948
+
step.status === "verifying") &&
949
+
step.name ===
950
+
"Enter the code sent to your email to complete PLC update" && (
951
+
<div class="mt-4 space-y-4">
952
+
<div class="bg-blue-50 dark:bg-blue-900/50 p-4 rounded-lg">
953
+
<p class="text-sm text-blue-800 dark:text-blue-200 mb-3">
954
+
Check your email for the verification code to complete
955
+
the PLC update:
956
+
</p>
957
+
<div class="flex space-x-2">
958
+
<div class="flex-1 relative">
959
+
<input
960
+
type="text"
961
+
value={emailToken}
962
+
onChange={(e) =>
963
+
setEmailToken(e.currentTarget.value)
964
+
}
965
+
placeholder="Enter verification code"
966
+
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
967
+
/>
968
+
</div>
969
+
<button
970
+
type="button"
971
+
onClick={handleTokenSubmit}
972
+
disabled={!emailToken || step.status === "verifying"}
973
+
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
974
+
>
975
+
<span>
976
+
{step.status === "verifying"
977
+
? "Verifying..."
978
+
: "Verify"}
979
+
</span>
980
+
<svg
981
+
class="w-4 h-4"
982
+
fill="none"
983
+
stroke="currentColor"
984
+
viewBox="0 0 24 24"
985
+
>
986
+
<path
987
+
stroke-linecap="round"
988
+
stroke-linejoin="round"
989
+
stroke-width="2"
990
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
991
+
/>
992
+
</svg>
993
+
</button>
994
+
</div>
995
+
{step.error && (
996
+
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md">
997
+
<p class="text-sm text-red-600 dark:text-red-400 flex items-center">
998
+
<svg
999
+
class="w-4 h-4 mr-1"
1000
+
fill="none"
1001
+
stroke="currentColor"
1002
+
viewBox="0 0 24 24"
1003
+
>
1004
+
<path
1005
+
stroke-linecap="round"
1006
+
stroke-linejoin="round"
1007
+
stroke-width="2"
1008
+
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1009
+
/>
1010
+
</svg>
1011
+
{step.error}
1012
+
</p>
1013
+
{step.error
1014
+
.toLowerCase()
1015
+
.includes("token is invalid") && (
1016
+
<div class="mt-2">
1017
+
<p class="text-sm text-red-500 dark:text-red-300 mb-2">
1018
+
The verification code may have expired. Request
1019
+
a new code to try again.
1020
+
</p>
1021
+
<button
1022
+
onClick={requestNewToken}
1023
+
class="text-sm px-3 py-1 bg-red-100 hover:bg-red-200 dark:bg-red-800 dark:hover:bg-red-700 text-red-700 dark:text-red-200 rounded-md transition-colors duration-200 flex items-center space-x-1"
1024
+
>
1025
+
<svg
1026
+
class="w-4 h-4"
1027
+
fill="none"
1028
+
stroke="currentColor"
1029
+
viewBox="0 0 24 24"
1030
+
>
1031
+
<path
1032
+
stroke-linecap="round"
1033
+
stroke-linejoin="round"
1034
+
stroke-width="2"
1035
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1036
+
/>
1037
+
</svg>
1038
+
<span>Request New Code</span>
1039
+
</button>
1040
+
</div>
1041
+
)}
1042
+
</div>
1043
+
)}
1044
+
</div>
1045
+
</div>
1046
+
)}
1047
+
</div>
1048
+
</div>
1049
+
))}
1050
+
</div>
1051
+
1052
+
{/* Success Message */}
1053
+
{steps[2].status === "completed" && (
1054
+
<div class="p-4 bg-green-50 dark:bg-green-900/50 rounded-lg border-2 border-green-200 dark:border-green-800">
1055
+
<div class="flex items-center space-x-3 mb-4">
1056
+
<svg
1057
+
class="w-6 h-6 text-green-500"
1058
+
fill="none"
1059
+
stroke="currentColor"
1060
+
viewBox="0 0 24 24"
1061
+
>
1062
+
<path
1063
+
stroke-linecap="round"
1064
+
stroke-linejoin="round"
1065
+
stroke-width="2"
1066
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
1067
+
/>
1068
+
</svg>
1069
+
<h4 class="text-lg font-medium text-green-800 dark:text-green-200">
1070
+
PLC Update Successful!
1071
+
</h4>
1072
+
</div>
1073
+
<p class="text-sm text-green-700 dark:text-green-300 mb-4">
1074
+
Your rotation key has been successfully added to your PLC record.
1075
+
You can now use this key for future DID modifications.
1076
+
</p>
1077
+
<div class="flex space-x-4">
1078
+
<button
1079
+
type="button"
1080
+
onClick={async () => {
1081
+
try {
1082
+
const response = await fetch("/api/logout", {
1083
+
method: "POST",
1084
+
credentials: "include",
1085
+
});
1086
+
if (!response.ok) {
1087
+
throw new Error("Logout failed");
1088
+
}
1089
+
globalThis.location.href = "/";
1090
+
} catch (error) {
1091
+
console.error("Failed to logout:", error);
1092
+
}
1093
+
}}
1094
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
1095
+
>
1096
+
<svg
1097
+
class="w-5 h-5"
1098
+
fill="none"
1099
+
stroke="currentColor"
1100
+
viewBox="0 0 24 24"
1101
+
>
1102
+
<path
1103
+
stroke-linecap="round"
1104
+
stroke-linejoin="round"
1105
+
stroke-width="2"
1106
+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
1107
+
/>
1108
+
</svg>
1109
+
<span>Sign Out</span>
1110
+
</button>
1111
+
<a
1112
+
href="https://ko-fi.com/knotbin"
1113
+
target="_blank"
1114
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
1115
+
>
1116
+
<svg
1117
+
class="w-5 h-5"
1118
+
fill="none"
1119
+
stroke="currentColor"
1120
+
viewBox="0 0 24 24"
1121
+
>
1122
+
<path
1123
+
stroke-linecap="round"
1124
+
stroke-linejoin="round"
1125
+
stroke-width="2"
1126
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
1127
+
/>
1128
+
</svg>
1129
+
<span>Support Us</span>
1130
+
</a>
1131
+
</div>
1132
+
</div>
1133
+
)}
1134
+
</div>
1135
+
);
1136
+
}
+5
islands/HandleInput.tsx
+5
islands/HandleInput.tsx
···
1
1
import { useState } from "preact/hooks";
2
2
import { JSX } from "preact";
3
3
4
+
/**
5
+
* The OAuth handle input form.
6
+
* @returns The handle input form
7
+
* @component
8
+
*/
4
9
export default function HandleInput() {
5
10
const [handle, setHandle] = useState("");
6
11
const [error, setError] = useState<string | null>(null);
+30
-6
islands/Header.tsx
+30
-6
islands/Header.tsx
···
2
2
import { IS_BROWSER } from "fresh/runtime";
3
3
import { Button } from "../components/Button.tsx";
4
4
5
+
/**
6
+
* The user interface.
7
+
* @type {User}
8
+
*/
5
9
interface User {
6
10
did: string;
7
11
handle?: string;
8
12
}
9
13
14
+
/**
15
+
* Truncate text to a maximum length.
16
+
* @param text - The text to truncate
17
+
* @param maxLength - The maximum length
18
+
* @returns The truncated text
19
+
*/
10
20
function truncateText(text: string, maxLength: number) {
11
21
if (text.length <= maxLength) return text;
12
22
let truncated = text.slice(0, maxLength);
···
17
27
return truncated + "...";
18
28
}
19
29
30
+
/**
31
+
* The header component.
32
+
* @returns The header component
33
+
* @component
34
+
*/
20
35
export default function Header() {
21
36
const [user, setUser] = useState<User | null>(null);
22
37
const [showDropdown, setShowDropdown] = useState(false);
···
36
51
setUser(
37
52
userData
38
53
? {
39
-
did: userData.did,
40
-
handle: userData.handle,
41
-
}
42
-
: null,
54
+
did: userData.did,
55
+
handle: userData.handle,
56
+
}
57
+
: null
43
58
);
44
59
} catch (error) {
45
60
console.error("Failed to fetch user:", error);
···
82
97
/>
83
98
84
99
<div className="flex items-center gap-3">
100
+
{/* Ticket booth (did:plc update) */}
101
+
<Button
102
+
href="/ticket-booth"
103
+
color="amber"
104
+
icon="/icons/ticket_bold.svg"
105
+
iconAlt="Ticket"
106
+
label="TICKET BOOTH"
107
+
/>
108
+
85
109
{/* Departures (Migration) */}
86
110
<Button
87
111
href="/migrate"
···
97
121
<div className="relative">
98
122
<Button
99
123
color="amber"
100
-
icon="/icons/ticket_bold.svg"
124
+
icon="/icons/account.svg"
101
125
iconAlt="Check-in"
102
126
label="CHECKED IN"
103
127
onClick={() => setShowDropdown(!showDropdown)}
···
126
150
<Button
127
151
href="/login"
128
152
color="amber"
129
-
icon="/icons/ticket_bold.svg"
153
+
icon="/icons/account.svg"
130
154
iconAlt="Check-in"
131
155
label="CHECK-IN"
132
156
/>
+35
islands/LoginButton.tsx
+35
islands/LoginButton.tsx
···
1
+
import { useEffect, useState } from "preact/hooks";
2
+
import { Button } from "../components/Button.tsx";
3
+
4
+
export default function LoginButton() {
5
+
const [isMobile, setIsMobile] = useState(true); // Default to mobile for SSR
6
+
7
+
useEffect(() => {
8
+
const checkMobile = () => {
9
+
setIsMobile(globalThis.innerWidth < 640);
10
+
};
11
+
12
+
// Check on mount
13
+
checkMobile();
14
+
15
+
// Listen for resize events
16
+
globalThis.addEventListener('resize', checkMobile);
17
+
return () => globalThis.removeEventListener('resize', checkMobile);
18
+
}, []);
19
+
20
+
return (
21
+
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
22
+
<Button
23
+
href={isMobile ? undefined : "/login"}
24
+
color="blue"
25
+
label={isMobile ? "MOBILE NOT SUPPORTED" : "GET STARTED"}
26
+
className={isMobile ? "opacity-50 cursor-not-allowed" : "opacity-100 cursor-pointer"}
27
+
onClick={(e: MouseEvent) => {
28
+
if (isMobile) {
29
+
e.preventDefault();
30
+
}
31
+
}}
32
+
/>
33
+
</div>
34
+
);
35
+
}
+5
islands/LoginSelector.tsx
+5
islands/LoginSelector.tsx
···
2
2
import HandleInput from "./HandleInput.tsx"
3
3
import CredLogin from "./CredLogin.tsx"
4
4
5
+
/**
6
+
* The login method selector for OAuth or Credential.
7
+
* @returns The login method selector
8
+
* @component
9
+
*/
5
10
export default function LoginMethodSelector() {
6
11
const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password')
7
12
+568
-199
islands/MigrationProgress.tsx
+568
-199
islands/MigrationProgress.tsx
···
1
1
import { useEffect, useState } from "preact/hooks";
2
2
3
+
/**
4
+
* The migration state info.
5
+
* @type {MigrationStateInfo}
6
+
*/
7
+
interface MigrationStateInfo {
8
+
state: "up" | "issue" | "maintenance";
9
+
message: string;
10
+
allowMigration: boolean;
11
+
}
12
+
13
+
/**
14
+
* The migration progress props.
15
+
* @type {MigrationProgressProps}
16
+
*/
3
17
interface MigrationProgressProps {
4
18
service: string;
5
19
handle: string;
···
8
22
invite?: string;
9
23
}
10
24
25
+
/**
26
+
* The migration step.
27
+
* @type {MigrationStep}
28
+
*/
11
29
interface MigrationStep {
12
30
name: string;
13
31
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
14
32
error?: string;
33
+
isVerificationError?: boolean;
15
34
}
16
35
36
+
/**
37
+
* The migration progress component.
38
+
* @param props - The migration progress props
39
+
* @returns The migration progress component
40
+
* @component
41
+
*/
17
42
export default function MigrationProgress(props: MigrationProgressProps) {
18
43
const [token, setToken] = useState("");
44
+
const [migrationState, setMigrationState] = useState<
45
+
MigrationStateInfo | null
46
+
>(null);
47
+
const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>(
48
+
{},
49
+
);
50
+
const [showContinueAnyway, setShowContinueAnyway] = useState<
51
+
Record<number, boolean>
52
+
>({});
19
53
20
54
const [steps, setSteps] = useState<MigrationStep[]>([
21
55
{ name: "Create Account", status: "pending" },
···
28
62
index: number,
29
63
status: MigrationStep["status"],
30
64
error?: string,
65
+
isVerificationError?: boolean,
31
66
) => {
32
67
console.log(
33
68
`Updating step ${index} to ${status}${
···
37
72
setSteps((prevSteps) =>
38
73
prevSteps.map((step, i) =>
39
74
i === index
40
-
? { ...step, status, error }
75
+
? { ...step, status, error, isVerificationError }
41
76
: i > index
42
-
? { ...step, status: "pending", error: undefined }
77
+
? {
78
+
...step,
79
+
status: "pending",
80
+
error: undefined,
81
+
isVerificationError: undefined,
82
+
}
43
83
: step
44
84
)
45
85
);
···
74
114
invite: props.invite,
75
115
});
76
116
77
-
if (!validateParams()) {
78
-
console.log("Parameter validation failed");
79
-
return;
80
-
}
117
+
// Check migration state first
118
+
const checkMigrationState = async () => {
119
+
try {
120
+
const migrationResponse = await fetch("/api/migration-state");
121
+
if (migrationResponse.ok) {
122
+
const migrationData = await migrationResponse.json();
123
+
setMigrationState(migrationData);
81
124
82
-
startMigration().catch((error) => {
83
-
console.error("Unhandled migration error:", error);
84
-
updateStepStatus(
85
-
0,
86
-
"error",
87
-
error instanceof Error ? error.message : String(error),
88
-
);
89
-
});
125
+
if (!migrationData.allowMigration) {
126
+
updateStepStatus(0, "error", migrationData.message);
127
+
return;
128
+
}
129
+
}
130
+
} catch (error) {
131
+
console.error("Failed to check migration state:", error);
132
+
updateStepStatus(0, "error", "Unable to verify migration availability");
133
+
return;
134
+
}
135
+
136
+
if (!validateParams()) {
137
+
console.log("Parameter validation failed");
138
+
return;
139
+
}
140
+
141
+
startMigration().catch((error) => {
142
+
console.error("Unhandled migration error:", error);
143
+
updateStepStatus(
144
+
0,
145
+
"error",
146
+
error.message || "Unknown error occurred",
147
+
);
148
+
});
149
+
};
150
+
151
+
checkMigrationState();
90
152
}, []);
91
153
92
154
const getStepDisplayName = (step: MigrationStep, index: number) => {
93
155
if (step.status === "completed") {
94
156
switch (index) {
95
-
case 0: return "Account Created";
96
-
case 1: return "Data Migrated";
97
-
case 2: return "Identity Migrated";
98
-
case 3: return "Migration Finalized";
157
+
case 0:
158
+
return "Account Created";
159
+
case 1:
160
+
return "Data Migrated";
161
+
case 2:
162
+
return "Identity Migrated";
163
+
case 3:
164
+
return "Migration Finalized";
99
165
}
100
166
}
101
-
167
+
102
168
if (step.status === "in-progress") {
103
169
switch (index) {
104
-
case 0: return "Creating your new account...";
105
-
case 1: return "Migrating your data...";
106
-
case 2: return step.name === "Enter the token sent to your email to complete identity migration"
107
-
? step.name
108
-
: "Migrating your identity...";
109
-
case 3: return "Finalizing migration...";
170
+
case 0:
171
+
return "Creating your new account...";
172
+
case 1:
173
+
return "Migrating your data...";
174
+
case 2:
175
+
return step.name ===
176
+
"Enter the token sent to your email to complete identity migration"
177
+
? step.name
178
+
: "Migrating your identity...";
179
+
case 3:
180
+
return "Finalizing migration...";
110
181
}
111
182
}
112
183
113
184
if (step.status === "verifying") {
114
185
switch (index) {
115
-
case 0: return "Verifying account creation...";
116
-
case 1: return "Verifying data migration...";
117
-
case 2: return "Verifying identity migration...";
118
-
case 3: return "Verifying migration completion...";
186
+
case 0:
187
+
return "Verifying account creation...";
188
+
case 1:
189
+
return "Verifying data migration...";
190
+
case 2:
191
+
return "Verifying identity migration...";
192
+
case 3:
193
+
return "Verifying migration completion...";
119
194
}
120
195
}
121
-
196
+
122
197
return step.name;
123
198
};
124
199
···
166
241
updateStepStatus(0, "verifying");
167
242
const verified = await verifyStep(0);
168
243
if (!verified) {
169
-
throw new Error("Account creation verification failed");
170
-
}
171
-
} catch (error) {
172
-
updateStepStatus(
173
-
0,
174
-
"error",
175
-
error instanceof Error ? error.message : String(error),
176
-
);
177
-
throw error;
178
-
}
179
-
180
-
// Step 2: Migrate Data
181
-
updateStepStatus(1, "in-progress");
182
-
console.log("Starting data migration...");
183
-
184
-
try {
185
-
const dataRes = await fetch("/api/migrate/data", {
186
-
method: "POST",
187
-
headers: { "Content-Type": "application/json" },
188
-
});
189
-
190
-
console.log("Data migration response status:", dataRes.status);
191
-
const dataText = await dataRes.text();
192
-
console.log("Data migration response:", dataText);
193
-
194
-
if (!dataRes.ok) {
195
-
try {
196
-
const json = JSON.parse(dataText);
197
-
throw new Error(json.message || "Failed to migrate data");
198
-
} catch {
199
-
throw new Error(dataText || "Failed to migrate data");
200
-
}
201
-
}
202
-
203
-
try {
204
-
const jsonData = JSON.parse(dataText);
205
-
if (!jsonData.success) {
206
-
throw new Error(jsonData.message || "Data migration failed");
207
-
}
208
-
console.log("Data migration successful:", jsonData);
209
-
} catch (e) {
210
-
console.error("Failed to parse data migration response:", e);
211
-
throw new Error("Invalid response from server during data migration");
212
-
}
213
-
214
-
updateStepStatus(1, "verifying");
215
-
const verified = await verifyStep(1);
216
-
if (!verified) {
217
-
throw new Error("Data migration verification failed");
218
-
}
219
-
} catch (error) {
220
-
updateStepStatus(
221
-
1,
222
-
"error",
223
-
error instanceof Error ? error.message : String(error),
224
-
);
225
-
throw error;
226
-
}
227
-
228
-
// Step 3: Request Identity Migration
229
-
updateStepStatus(2, "in-progress");
230
-
console.log("Requesting identity migration...");
231
-
232
-
try {
233
-
const requestRes = await fetch("/api/migrate/identity/request", {
234
-
method: "POST",
235
-
headers: { "Content-Type": "application/json" },
236
-
});
237
-
238
-
console.log("Identity request response status:", requestRes.status);
239
-
const requestText = await requestRes.text();
240
-
console.log("Identity request response:", requestText);
241
-
242
-
if (!requestRes.ok) {
243
-
try {
244
-
const json = JSON.parse(requestText);
245
-
throw new Error(json.message || "Failed to request identity migration");
246
-
} catch {
247
-
throw new Error(requestText || "Failed to request identity migration");
248
-
}
249
-
}
250
-
251
-
try {
252
-
const jsonData = JSON.parse(requestText);
253
-
if (!jsonData.success) {
254
-
throw new Error(
255
-
jsonData.message || "Identity migration request failed",
256
-
);
257
-
}
258
-
console.log("Identity migration requested successfully");
259
-
260
-
// Update step name to prompt for token
261
-
setSteps(prevSteps =>
262
-
prevSteps.map((step, i) =>
263
-
i === 2
264
-
? { ...step, name: "Enter the token sent to your email to complete identity migration" }
265
-
: step
266
-
)
244
+
console.log(
245
+
"Account creation: Verification failed, waiting for user action",
267
246
);
268
-
// Don't continue with migration - wait for token input
269
247
return;
270
-
} catch (e) {
271
-
console.error("Failed to parse identity request response:", e);
272
-
throw new Error(
273
-
"Invalid response from server during identity request",
274
-
);
275
248
}
249
+
250
+
// If verification succeeds, continue to data migration
251
+
await startDataMigration();
276
252
} catch (error) {
277
253
updateStepStatus(
278
-
2,
254
+
0,
279
255
"error",
280
256
error instanceof Error ? error.message : String(error),
281
257
);
···
302
278
if (!identityRes.ok) {
303
279
try {
304
280
const json = JSON.parse(identityData);
305
-
throw new Error(json.message || "Failed to complete identity migration");
281
+
throw new Error(
282
+
json.message || "Failed to complete identity migration",
283
+
);
306
284
} catch {
307
-
throw new Error(identityData || "Failed to complete identity migration");
285
+
throw new Error(
286
+
identityData || "Failed to complete identity migration",
287
+
);
308
288
}
309
289
}
310
290
···
318
298
throw new Error("Invalid response from server");
319
299
}
320
300
321
-
322
301
updateStepStatus(2, "verifying");
323
302
const verified = await verifyStep(2);
324
303
if (!verified) {
325
-
throw new Error("Identity migration verification failed");
326
-
}
327
-
328
-
// Step 4: Finalize Migration
329
-
updateStepStatus(3, "in-progress");
330
-
try {
331
-
const finalizeRes = await fetch("/api/migrate/finalize", {
332
-
method: "POST",
333
-
headers: { "Content-Type": "application/json" },
334
-
});
335
-
336
-
const finalizeData = await finalizeRes.text();
337
-
if (!finalizeRes.ok) {
338
-
try {
339
-
const json = JSON.parse(finalizeData);
340
-
throw new Error(json.message || "Failed to finalize migration");
341
-
} catch {
342
-
throw new Error(finalizeData || "Failed to finalize migration");
343
-
}
344
-
}
345
-
346
-
try {
347
-
const jsonData = JSON.parse(finalizeData);
348
-
if (!jsonData.success) {
349
-
throw new Error(jsonData.message || "Finalization failed");
350
-
}
351
-
} catch {
352
-
throw new Error("Invalid response from server during finalization");
353
-
}
354
-
355
-
updateStepStatus(3, "verifying");
356
-
const verified = await verifyStep(3);
357
-
if (!verified) {
358
-
throw new Error("Migration finalization verification failed");
359
-
}
360
-
} catch (error) {
361
-
updateStepStatus(
362
-
3,
363
-
"error",
364
-
error instanceof Error ? error.message : String(error),
304
+
console.log(
305
+
"Identity migration: Verification failed, waiting for user action",
365
306
);
366
-
throw error;
307
+
return;
367
308
}
309
+
310
+
// If verification succeeds, continue to finalization
311
+
await startFinalization();
368
312
} catch (error) {
369
313
console.error("Identity migration error:", error);
370
314
updateStepStatus(
···
453
397
454
398
// Helper to verify a step after completion
455
399
const verifyStep = async (stepNum: number) => {
400
+
console.log(`Verification: Starting step ${stepNum + 1}`);
456
401
updateStepStatus(stepNum, "verifying");
457
402
try {
403
+
console.log(`Verification: Fetching status for step ${stepNum + 1}`);
458
404
const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`);
405
+
console.log(`Verification: Status response status:`, res.status);
459
406
const data = await res.json();
407
+
console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
408
+
460
409
if (data.ready) {
410
+
console.log(`Verification: Step ${stepNum + 1} is ready`);
461
411
updateStepStatus(stepNum, "completed");
412
+
// Reset retry state on success
413
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 }));
414
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
415
+
416
+
// Continue to next step if not the last one
417
+
if (stepNum < 3) {
418
+
setTimeout(() => continueToNextStep(stepNum + 1), 500);
419
+
}
420
+
462
421
return true;
463
422
} else {
464
-
updateStepStatus(stepNum, "error", data.reason || "Verification failed");
423
+
console.log(
424
+
`Verification: Step ${stepNum + 1} is not ready:`,
425
+
data.reason,
426
+
);
427
+
const statusDetails = {
428
+
activated: data.activated,
429
+
validDid: data.validDid,
430
+
repoCommit: data.repoCommit,
431
+
repoRev: data.repoRev,
432
+
repoBlocks: data.repoBlocks,
433
+
expectedRecords: data.expectedRecords,
434
+
indexedRecords: data.indexedRecords,
435
+
privateStateValues: data.privateStateValues,
436
+
expectedBlobs: data.expectedBlobs,
437
+
importedBlobs: data.importedBlobs,
438
+
};
439
+
console.log(
440
+
`Verification: Step ${stepNum + 1} status details:`,
441
+
statusDetails,
442
+
);
443
+
const errorMessage = `${
444
+
data.reason || "Verification failed"
445
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
446
+
447
+
// Track retry attempts
448
+
const currentAttempts = retryAttempts[stepNum] || 0;
449
+
setRetryAttempts((prev) => ({
450
+
...prev,
451
+
[stepNum]: currentAttempts + 1,
452
+
}));
453
+
454
+
// Show continue anyway option if this is the second failure
455
+
if (currentAttempts >= 1) {
456
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
457
+
}
458
+
459
+
updateStepStatus(stepNum, "error", errorMessage, true);
465
460
return false;
466
461
}
467
462
} catch (e) {
468
-
updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(e));
463
+
console.error(`Verification: Error in step ${stepNum + 1}:`, e);
464
+
const currentAttempts = retryAttempts[stepNum] || 0;
465
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 }));
466
+
467
+
// Show continue anyway option if this is the second failure
468
+
if (currentAttempts >= 1) {
469
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
470
+
}
471
+
472
+
updateStepStatus(
473
+
stepNum,
474
+
"error",
475
+
e instanceof Error ? e.message : String(e),
476
+
true,
477
+
);
469
478
return false;
470
479
}
471
480
};
472
481
482
+
const retryVerification = async (stepNum: number) => {
483
+
console.log(`Retrying verification for step ${stepNum + 1}`);
484
+
await verifyStep(stepNum);
485
+
};
486
+
487
+
const continueAnyway = (stepNum: number) => {
488
+
console.log(`Continuing anyway for step ${stepNum + 1}`);
489
+
updateStepStatus(stepNum, "completed");
490
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
491
+
492
+
// Continue with next step if not the last one
493
+
if (stepNum < 3) {
494
+
continueToNextStep(stepNum + 1);
495
+
}
496
+
};
497
+
498
+
const continueToNextStep = async (stepNum: number) => {
499
+
switch (stepNum) {
500
+
case 1:
501
+
// Continue to data migration
502
+
await startDataMigration();
503
+
break;
504
+
case 2:
505
+
// Continue to identity migration
506
+
await startIdentityMigration();
507
+
break;
508
+
case 3:
509
+
// Continue to finalization
510
+
await startFinalization();
511
+
break;
512
+
}
513
+
};
514
+
515
+
const startDataMigration = async () => {
516
+
// Step 2: Migrate Data
517
+
updateStepStatus(1, "in-progress");
518
+
console.log("Starting data migration...");
519
+
520
+
try {
521
+
// Step 2.1: Migrate Repo
522
+
console.log("Data migration: Starting repo migration");
523
+
const repoRes = await fetch("/api/migrate/data/repo", {
524
+
method: "POST",
525
+
headers: { "Content-Type": "application/json" },
526
+
});
527
+
528
+
console.log("Repo migration: Response status:", repoRes.status);
529
+
const repoText = await repoRes.text();
530
+
console.log("Repo migration: Raw response:", repoText);
531
+
532
+
if (!repoRes.ok) {
533
+
try {
534
+
const json = JSON.parse(repoText);
535
+
console.error("Repo migration: Error response:", json);
536
+
throw new Error(json.message || "Failed to migrate repo");
537
+
} catch {
538
+
console.error("Repo migration: Non-JSON error response:", repoText);
539
+
throw new Error(repoText || "Failed to migrate repo");
540
+
}
541
+
}
542
+
543
+
// Step 2.2: Migrate Blobs
544
+
console.log("Data migration: Starting blob migration");
545
+
const blobsRes = await fetch("/api/migrate/data/blobs", {
546
+
method: "POST",
547
+
headers: { "Content-Type": "application/json" },
548
+
});
549
+
550
+
console.log("Blob migration: Response status:", blobsRes.status);
551
+
const blobsText = await blobsRes.text();
552
+
console.log("Blob migration: Raw response:", blobsText);
553
+
554
+
if (!blobsRes.ok) {
555
+
try {
556
+
const json = JSON.parse(blobsText);
557
+
console.error("Blob migration: Error response:", json);
558
+
throw new Error(json.message || "Failed to migrate blobs");
559
+
} catch {
560
+
console.error(
561
+
"Blob migration: Non-JSON error response:",
562
+
blobsText,
563
+
);
564
+
throw new Error(blobsText || "Failed to migrate blobs");
565
+
}
566
+
}
567
+
568
+
// Step 2.3: Migrate Preferences
569
+
console.log("Data migration: Starting preferences migration");
570
+
const prefsRes = await fetch("/api/migrate/data/prefs", {
571
+
method: "POST",
572
+
headers: { "Content-Type": "application/json" },
573
+
});
574
+
575
+
console.log("Preferences migration: Response status:", prefsRes.status);
576
+
const prefsText = await prefsRes.text();
577
+
console.log("Preferences migration: Raw response:", prefsText);
578
+
579
+
if (!prefsRes.ok) {
580
+
try {
581
+
const json = JSON.parse(prefsText);
582
+
console.error("Preferences migration: Error response:", json);
583
+
throw new Error(json.message || "Failed to migrate preferences");
584
+
} catch {
585
+
console.error(
586
+
"Preferences migration: Non-JSON error response:",
587
+
prefsText,
588
+
);
589
+
throw new Error(prefsText || "Failed to migrate preferences");
590
+
}
591
+
}
592
+
593
+
console.log("Data migration: Starting verification");
594
+
updateStepStatus(1, "verifying");
595
+
const verified = await verifyStep(1);
596
+
console.log("Data migration: Verification result:", verified);
597
+
if (!verified) {
598
+
console.log(
599
+
"Data migration: Verification failed, waiting for user action",
600
+
);
601
+
return;
602
+
}
603
+
604
+
// If verification succeeds, continue to next step
605
+
await startIdentityMigration();
606
+
} catch (error) {
607
+
console.error("Data migration: Error caught:", error);
608
+
updateStepStatus(
609
+
1,
610
+
"error",
611
+
error instanceof Error ? error.message : String(error),
612
+
);
613
+
throw error;
614
+
}
615
+
};
616
+
617
+
const startIdentityMigration = async () => {
618
+
// Step 3: Request Identity Migration
619
+
updateStepStatus(2, "in-progress");
620
+
console.log("Requesting identity migration...");
621
+
622
+
try {
623
+
const requestRes = await fetch("/api/migrate/identity/request", {
624
+
method: "POST",
625
+
headers: { "Content-Type": "application/json" },
626
+
});
627
+
628
+
console.log("Identity request response status:", requestRes.status);
629
+
const requestText = await requestRes.text();
630
+
console.log("Identity request response:", requestText);
631
+
632
+
if (!requestRes.ok) {
633
+
try {
634
+
const json = JSON.parse(requestText);
635
+
throw new Error(
636
+
json.message || "Failed to request identity migration",
637
+
);
638
+
} catch {
639
+
throw new Error(
640
+
requestText || "Failed to request identity migration",
641
+
);
642
+
}
643
+
}
644
+
645
+
try {
646
+
const jsonData = JSON.parse(requestText);
647
+
if (!jsonData.success) {
648
+
throw new Error(
649
+
jsonData.message || "Identity migration request failed",
650
+
);
651
+
}
652
+
console.log("Identity migration requested successfully");
653
+
654
+
// Update step name to prompt for token
655
+
setSteps((prevSteps) =>
656
+
prevSteps.map((step, i) =>
657
+
i === 2
658
+
? {
659
+
...step,
660
+
name:
661
+
"Enter the token sent to your email to complete identity migration",
662
+
}
663
+
: step
664
+
)
665
+
);
666
+
// Don't continue with migration - wait for token input
667
+
return;
668
+
} catch (e) {
669
+
console.error("Failed to parse identity request response:", e);
670
+
throw new Error(
671
+
"Invalid response from server during identity request",
672
+
);
673
+
}
674
+
} catch (error) {
675
+
updateStepStatus(
676
+
2,
677
+
"error",
678
+
error instanceof Error ? error.message : String(error),
679
+
);
680
+
throw error;
681
+
}
682
+
};
683
+
684
+
const startFinalization = async () => {
685
+
// Step 4: Finalize Migration
686
+
updateStepStatus(3, "in-progress");
687
+
try {
688
+
const finalizeRes = await fetch("/api/migrate/finalize", {
689
+
method: "POST",
690
+
headers: { "Content-Type": "application/json" },
691
+
});
692
+
693
+
const finalizeData = await finalizeRes.text();
694
+
if (!finalizeRes.ok) {
695
+
try {
696
+
const json = JSON.parse(finalizeData);
697
+
throw new Error(json.message || "Failed to finalize migration");
698
+
} catch {
699
+
throw new Error(finalizeData || "Failed to finalize migration");
700
+
}
701
+
}
702
+
703
+
try {
704
+
const jsonData = JSON.parse(finalizeData);
705
+
if (!jsonData.success) {
706
+
throw new Error(jsonData.message || "Finalization failed");
707
+
}
708
+
} catch {
709
+
throw new Error("Invalid response from server during finalization");
710
+
}
711
+
712
+
updateStepStatus(3, "verifying");
713
+
const verified = await verifyStep(3);
714
+
if (!verified) {
715
+
console.log(
716
+
"Finalization: Verification failed, waiting for user action",
717
+
);
718
+
return;
719
+
}
720
+
} catch (error) {
721
+
updateStepStatus(
722
+
3,
723
+
"error",
724
+
error instanceof Error ? error.message : String(error),
725
+
);
726
+
throw error;
727
+
}
728
+
};
729
+
473
730
return (
474
731
<div class="space-y-8">
732
+
{/* Migration state alert */}
733
+
{migrationState && !migrationState.allowMigration && (
734
+
<div
735
+
class={`p-4 rounded-lg border ${
736
+
migrationState.state === "maintenance"
737
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
738
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
739
+
}`}
740
+
>
741
+
<div class="flex items-center">
742
+
<div
743
+
class={`mr-3 ${
744
+
migrationState.state === "maintenance"
745
+
? "text-yellow-600 dark:text-yellow-400"
746
+
: "text-red-600 dark:text-red-400"
747
+
}`}
748
+
>
749
+
{migrationState.state === "maintenance" ? "โ ๏ธ" : "๐ซ"}
750
+
</div>
751
+
<div>
752
+
<h3 class="font-semibold mb-1">
753
+
{migrationState.state === "maintenance"
754
+
? "Maintenance Mode"
755
+
: "Service Unavailable"}
756
+
</h3>
757
+
<p class="text-sm">{migrationState.message}</p>
758
+
</div>
759
+
</div>
760
+
</div>
761
+
)}
762
+
475
763
<div class="space-y-4">
476
764
{steps.map((step, index) => (
477
765
<div key={step.name} class={getStepClasses(step.status)}>
···
491
779
{getStepDisplayName(step, index)}
492
780
</p>
493
781
{step.error && (
494
-
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
495
-
{(() => {
496
-
try {
497
-
const err = JSON.parse(step.error);
498
-
return err.message || step.error;
499
-
} catch {
500
-
return step.error;
501
-
}
502
-
})()}
503
-
</p>
782
+
<div class="mt-1">
783
+
<p class="text-sm text-red-600 dark:text-red-400">
784
+
{(() => {
785
+
try {
786
+
const err = JSON.parse(step.error);
787
+
return err.message || step.error;
788
+
} catch {
789
+
return step.error;
790
+
}
791
+
})()}
792
+
</p>
793
+
{step.isVerificationError && (
794
+
<div class="flex space-x-2 mt-2">
795
+
<button
796
+
type="button"
797
+
onClick={() => retryVerification(index)}
798
+
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
799
+
>
800
+
Retry Verification
801
+
</button>
802
+
{showContinueAnyway[index] && (
803
+
<button
804
+
type="button"
805
+
onClick={() => continueAnyway(index)}
806
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
807
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
808
+
>
809
+
Continue Anyway
810
+
</button>
811
+
)}
812
+
</div>
813
+
)}
814
+
</div>
504
815
)}
505
816
{index === 2 && step.status === "in-progress" &&
506
-
step.name === "Enter the token sent to your email to complete identity migration" && (
817
+
step.name ===
818
+
"Enter the token sent to your email to complete identity migration" &&
819
+
(
507
820
<div class="mt-4 space-y-4">
508
821
<p class="text-sm text-blue-800 dark:text-blue-200">
509
-
Please check your email for the migration token and enter it below:
822
+
Please check your email for the migration token and enter
823
+
it below:
510
824
</p>
511
825
<div class="flex space-x-2">
512
826
<input
···
525
839
</button>
526
840
</div>
527
841
</div>
528
-
)
529
-
}
842
+
)}
530
843
</div>
531
844
</div>
532
845
))}
533
846
</div>
534
847
535
-
536
-
537
848
{steps[3].status === "completed" && (
538
849
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
539
-
<p class="text-sm text-green-800 dark:text-green-200">
540
-
Migration completed successfully! You can now close this page.
850
+
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
851
+
Migration completed successfully! Sign out to finish the process and
852
+
return home.<br />
853
+
Please consider donating to Airport to support server and
854
+
development costs.
541
855
</p>
856
+
<div class="flex space-x-4">
857
+
<button
858
+
type="button"
859
+
onClick={async () => {
860
+
try {
861
+
const response = await fetch("/api/logout", {
862
+
method: "POST",
863
+
credentials: "include",
864
+
});
865
+
if (!response.ok) {
866
+
throw new Error("Logout failed");
867
+
}
868
+
globalThis.location.href = "/";
869
+
} catch (error) {
870
+
console.error("Failed to logout:", error);
871
+
}
872
+
}}
873
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
874
+
>
875
+
<svg
876
+
class="w-5 h-5"
877
+
fill="none"
878
+
stroke="currentColor"
879
+
viewBox="0 0 24 24"
880
+
>
881
+
<path
882
+
stroke-linecap="round"
883
+
stroke-linejoin="round"
884
+
stroke-width="2"
885
+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
886
+
/>
887
+
</svg>
888
+
<span>Sign Out</span>
889
+
</button>
890
+
<a
891
+
href="https://ko-fi.com/knotbin"
892
+
target="_blank"
893
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
894
+
>
895
+
<svg
896
+
class="w-5 h-5"
897
+
fill="none"
898
+
stroke="currentColor"
899
+
viewBox="0 0 24 24"
900
+
>
901
+
<path
902
+
stroke-linecap="round"
903
+
stroke-linejoin="round"
904
+
stroke-width="2"
905
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
906
+
/>
907
+
</svg>
908
+
<span>Support Us</span>
909
+
</a>
910
+
</div>
542
911
</div>
543
912
)}
544
913
</div>
+173
-8
islands/MigrationSetup.tsx
+173
-8
islands/MigrationSetup.tsx
···
1
-
import { useState } from "preact/hooks";
1
+
import { useState, useEffect } from "preact/hooks";
2
+
import { IS_BROWSER } from "fresh/runtime";
2
3
4
+
/**
5
+
* The migration setup props.
6
+
* @type {MigrationSetupProps}
7
+
*/
3
8
interface MigrationSetupProps {
4
9
service?: string | null;
5
10
handle?: string | null;
···
7
12
invite?: string | null;
8
13
}
9
14
15
+
/**
16
+
* The server description.
17
+
* @type {ServerDescription}
18
+
*/
10
19
interface ServerDescription {
11
20
inviteCodeRequired: boolean;
12
21
availableUserDomains: string[];
13
22
}
14
23
24
+
/**
25
+
* The user passport.
26
+
* @type {UserPassport}
27
+
*/
28
+
interface UserPassport {
29
+
did: string;
30
+
handle: string;
31
+
pds: string;
32
+
createdAt?: string;
33
+
}
34
+
35
+
/**
36
+
* The migration state info.
37
+
* @type {MigrationStateInfo}
38
+
*/
39
+
interface MigrationStateInfo {
40
+
state: "up" | "issue" | "maintenance";
41
+
message: string;
42
+
allowMigration: boolean;
43
+
}
44
+
45
+
/**
46
+
* The migration setup component.
47
+
* @param props - The migration setup props
48
+
* @returns The migration setup component
49
+
* @component
50
+
*/
15
51
export default function MigrationSetup(props: MigrationSetupProps) {
16
52
const [service, setService] = useState(props.service || "");
17
53
const [handlePrefix, setHandlePrefix] = useState(
···
27
63
const [isLoading, setIsLoading] = useState(false);
28
64
const [showConfirmation, setShowConfirmation] = useState(false);
29
65
const [confirmationText, setConfirmationText] = useState("");
66
+
const [passport, setPassport] = useState<UserPassport | null>(null);
67
+
const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null);
68
+
69
+
const ensureServiceUrl = (url: string): string => {
70
+
if (!url) return url;
71
+
try {
72
+
// If it already has a protocol, return as is
73
+
new URL(url);
74
+
return url;
75
+
} catch {
76
+
// If no protocol, add https://
77
+
return `https://${url}`;
78
+
}
79
+
};
80
+
81
+
useEffect(() => {
82
+
if (!IS_BROWSER) return;
83
+
84
+
const fetchInitialData = async () => {
85
+
try {
86
+
// Check migration state first
87
+
const migrationResponse = await fetch("/api/migration-state");
88
+
if (migrationResponse.ok) {
89
+
const migrationData = await migrationResponse.json();
90
+
setMigrationState(migrationData);
91
+
}
92
+
93
+
// Fetch user passport
94
+
const response = await fetch("/api/me", {
95
+
credentials: "include",
96
+
});
97
+
if (!response.ok) {
98
+
throw new Error("Failed to fetch user profile");
99
+
}
100
+
const userData = await response.json();
101
+
if (userData) {
102
+
// Get PDS URL from the current service
103
+
const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`);
104
+
const pdsData = await pdsResponse.json();
105
+
106
+
setPassport({
107
+
did: userData.did,
108
+
handle: userData.handle,
109
+
pds: pdsData.pds || "Unknown",
110
+
createdAt: new Date().toISOString() // TODO: Get actual creation date from API
111
+
});
112
+
}
113
+
} catch (error) {
114
+
console.error("Failed to fetch initial data:", error);
115
+
}
116
+
};
117
+
118
+
fetchInitialData();
119
+
}, []);
30
120
31
121
const checkServerDescription = async (serviceUrl: string) => {
32
122
try {
···
59
149
};
60
150
61
151
const handleServiceChange = (value: string) => {
62
-
setService(value);
152
+
const urlWithProtocol = ensureServiceUrl(value);
153
+
setService(urlWithProtocol);
63
154
setError("");
64
-
if (value) {
65
-
checkServerDescription(value);
155
+
if (urlWithProtocol) {
156
+
checkServerDescription(urlWithProtocol);
66
157
} else {
67
158
setAvailableDomains([]);
68
159
setSelectedDomain("");
···
72
163
const handleSubmit = (e: Event) => {
73
164
e.preventDefault();
74
165
166
+
// Check migration state first
167
+
if (migrationState && !migrationState.allowMigration) {
168
+
setError(migrationState.message);
169
+
return;
170
+
}
171
+
75
172
if (!service || !handlePrefix || !email || !password) {
76
173
setError("Please fill in all required fields");
77
174
return;
···
86
183
};
87
184
88
185
const handleConfirmation = () => {
186
+
// Double-check migration state before proceeding
187
+
if (migrationState && !migrationState.allowMigration) {
188
+
setError(migrationState.message);
189
+
return;
190
+
}
191
+
89
192
if (confirmationText !== "MIGRATE") {
90
193
setError("Please type 'MIGRATE' to confirm");
91
194
return;
···
117
220
<div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div>
118
221
<div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div>
119
222
223
+
{/* Migration state alert */}
224
+
{migrationState && !migrationState.allowMigration && (
225
+
<div class={`mb-6 mt-4 p-4 rounded-lg border ${
226
+
migrationState.state === "maintenance"
227
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
228
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
229
+
}`}>
230
+
<div class="flex items-center">
231
+
<div class={`mr-3 ${
232
+
migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400"
233
+
}`}>
234
+
{migrationState.state === "maintenance" ? "โ ๏ธ" : "๐ซ"}
235
+
</div>
236
+
<div>
237
+
<h3 class="font-semibold mb-1">
238
+
{migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"}
239
+
</h3>
240
+
<p class="text-sm">{migrationState.message}</p>
241
+
</div>
242
+
</div>
243
+
</div>
244
+
)}
245
+
120
246
<div class="text-center mb-8 relative">
121
247
<p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p>
122
248
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div>
123
249
</div>
124
250
251
+
{/* Passport Section */}
252
+
{passport && (
253
+
<div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
254
+
<div class="flex items-center justify-between mb-4">
255
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Current Passport</h3>
256
+
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">ISSUED: {new Date().toLocaleDateString()}</div>
257
+
</div>
258
+
<div class="grid grid-cols-2 gap-4 text-sm">
259
+
<div>
260
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div>
261
+
<div class="font-mono text-gray-900 dark:text-white">{passport.handle}</div>
262
+
</div>
263
+
<div>
264
+
<div class="text-gray-500 dark:text-gray-400 mb-1">DID</div>
265
+
<div class="font-mono text-gray-900 dark:text-white break-all">{passport.did}</div>
266
+
</div>
267
+
<div>
268
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Citizen of PDS</div>
269
+
<div class="font-mono text-gray-900 dark:text-white break-all">{passport.pds}</div>
270
+
</div>
271
+
<div>
272
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Account Age</div>
273
+
<div class="font-mono text-gray-900 dark:text-white">
274
+
{passport.createdAt ? new Date(passport.createdAt).toLocaleDateString() : "Unknown"}
275
+
</div>
276
+
</div>
277
+
</div>
278
+
</div>
279
+
)}
280
+
125
281
<form onSubmit={handleSubmit} class="space-y-6">
126
282
{error && (
127
-
<div class="bg-red-50 dark:bg-red-900 rounded-lg">
283
+
<div class="bg-red-50 dark:bg-red-900 rounded-lg ">
128
284
<p class="text-red-800 dark:text-red-200 flex items-center">
129
285
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
130
286
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
···
171
327
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
172
328
New Account Handle
173
329
<span class="text-xs text-gray-500 ml-1">(Passport ID)</span>
330
+
<div class="inline-block relative group ml-2">
331
+
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" fill="currentColor" viewBox="0 0 20 20">
332
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
333
+
</svg>
334
+
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
335
+
You can change your handle to a custom domain later
336
+
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
337
+
</div>
338
+
</div>
174
339
</label>
175
340
<div class="mt-1 relative w-full">
176
341
<div class="flex rounded-md shadow-sm w-full">
···
221
386
222
387
<div>
223
388
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
224
-
Contact Email
389
+
Email
225
390
<span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span>
226
391
</label>
227
392
<div class="relative">
···
287
452
288
453
<button
289
454
type="submit"
290
-
disabled={isLoading}
455
+
disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)}
291
456
class="w-full flex justify-center items-center py-3 px-4 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
292
457
>
293
458
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
···
313
478
<div class="text-center mb-4 mt-6">
314
479
<h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3>
315
480
<p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
316
-
<span class="font-semibold text-red-500">Warning:</span> This migration process can be <strong>irreversible</strong>.<br />Airport is in <strong>alpha</strong> currently, and we don't recommend it for main accounts.
481
+
<span class="font-semibold text-red-500">Warning:</span> This migration is <strong>irreversible</strong> if coming from Bluesky servers.<br />Bluesky does not recommend it for main accounts. Migrate at your own risk. We reccomend backing up your data before proceeding.
317
482
</p>
318
483
<p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
319
484
Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
+10
islands/OAuthCallback.tsx
+10
islands/OAuthCallback.tsx
···
1
1
import { useEffect, useState } from "preact/hooks";
2
2
import { IS_BROWSER } from "fresh/runtime";
3
3
4
+
/**
5
+
* The OAuth callback props.
6
+
* @type {OAuthCallbackProps}
7
+
*/
4
8
interface OAuthCallbackProps {
5
9
error?: string;
6
10
}
7
11
12
+
/**
13
+
* The OAuth callback component.
14
+
* @param props - The OAuth callback props
15
+
* @returns The OAuth callback component
16
+
* @component
17
+
*/
8
18
export default function OAuthCallback(
9
19
{ error: initialError }: OAuthCallbackProps,
10
20
) {
+9
islands/SocialLinks.tsx
+9
islands/SocialLinks.tsx
···
1
1
import { useEffect, useState } from "preact/hooks";
2
2
import * as Icon from 'npm:preact-feather';
3
3
4
+
/**
5
+
* The GitHub repository.
6
+
* @type {GitHubRepo}
7
+
*/
4
8
interface GitHubRepo {
5
9
stargazers_count: number;
6
10
}
7
11
12
+
/**
13
+
* The social links component.
14
+
* @returns The social links component
15
+
* @component
16
+
*/
8
17
export default function SocialLinks() {
9
18
const [starCount, setStarCount] = useState<number | null>(null);
10
19
+24
-7
islands/Ticket.tsx
+24
-7
islands/Ticket.tsx
···
1
1
import { useEffect, useState } from "preact/hooks";
2
2
import { IS_BROWSER } from "fresh/runtime";
3
+
import { Link } from "../components/Link.tsx";
3
4
5
+
/**
6
+
* The user interface for the ticket component.
7
+
* @type {User}
8
+
*/
4
9
interface User {
5
10
did: string;
6
11
handle?: string;
7
12
}
8
13
14
+
/**
15
+
* The ticket component for the landing page.
16
+
* @returns The ticket component
17
+
* @component
18
+
*/
9
19
export default function Ticket() {
10
20
const [user, setUser] = useState<User | null>(null);
11
21
···
24
34
setUser(
25
35
userData
26
36
? {
27
-
did: userData.did,
28
-
handle: userData.handle,
29
-
}
30
-
: null,
37
+
did: userData.did,
38
+
handle: userData.handle,
39
+
}
40
+
: null
31
41
);
32
42
} catch (error) {
33
43
console.error("Failed to fetch user:", error);
···
63
73
</p>
64
74
<p>
65
75
Think you might need to migrate in the future but your PDS might be
66
-
hostile or offline? No worries! Soon you'll be able to go to the
67
-
ticket booth and get a PLC key to use for account recovery in the
68
-
future. You can also go to baggage claim (take the air shuttle to
76
+
hostile or offline? No worries! You can go to the{" "}
77
+
<Link
78
+
href="/ticket-booth"
79
+
isExternal
80
+
class="text-blue-600 dark:text-blue-400"
81
+
>
82
+
ticket booth
83
+
</Link>{" "}
84
+
and get a PLC key to use for account recovery in the future. Soon
85
+
you'll also be able to go to baggage claim (take the air shuttle to
69
86
terminal four) and get a downloadable backup of all your current PDS
70
87
data in case that were to happen.
71
88
</p>
+7
lib/check-dids.ts
+7
lib/check-dids.ts
···
1
+
import { getSession } from "./sessions.ts";
2
+
3
+
export async function checkDidsMatch(req: Request): Promise<boolean> {
4
+
const oldSession = await getSession(req, undefined, false);
5
+
const newSession = await getSession(req, undefined, true);
6
+
return oldSession.did === newSession.did;
7
+
}
+55
-29
lib/cred/sessions.ts
+55
-29
lib/cred/sessions.ts
···
5
5
let migrationSessionOptions: SessionOptions;
6
6
let credentialSessionOptions: SessionOptions;
7
7
8
+
/**
9
+
* Get the session options for the given request.
10
+
* @param isMigration - Whether to get the migration session options
11
+
* @returns The session options
12
+
*/
8
13
async function getOptions(isMigration: boolean) {
9
14
if (isMigration) {
10
15
if (!migrationSessionOptions) {
···
12
17
}
13
18
return migrationSessionOptions;
14
19
}
15
-
20
+
16
21
if (!credentialSessionOptions) {
17
22
credentialSessionOptions = await createSessionOptions("cred_sid");
18
23
}
19
24
return credentialSessionOptions;
20
25
}
21
26
27
+
/**
28
+
* Get the credential session for the given request.
29
+
* @param req - The request object
30
+
* @param res - The response object
31
+
* @param isMigration - Whether to get the migration session
32
+
* @returns The credential session
33
+
*/
22
34
export async function getCredentialSession(
23
35
req: Request,
24
36
res: Response = new Response(),
25
37
isMigration: boolean = false
26
38
) {
27
39
const options = await getOptions(isMigration);
28
-
return getIronSession<CredentialSession>(
29
-
req,
30
-
res,
31
-
options,
32
-
);
40
+
return getIronSession<CredentialSession>(req, res, options);
33
41
}
34
42
43
+
/**
44
+
* Get the credential agent for the given request.
45
+
* @param req - The request object
46
+
* @param res - The response object
47
+
* @param isMigration - Whether to get the migration session
48
+
* @returns The credential agent
49
+
*/
35
50
export async function getCredentialAgent(
36
51
req: Request,
37
52
res: Response = new Response(),
38
-
isMigration: boolean = false,
53
+
isMigration: boolean = false
39
54
) {
40
-
const session = await getCredentialSession(
41
-
req,
42
-
res,
43
-
isMigration
44
-
);
45
-
if (!session.did || !session.service || !session.handle || !session.password) {
55
+
const session = await getCredentialSession(req, res, isMigration);
56
+
if (
57
+
!session.did ||
58
+
!session.service ||
59
+
!session.handle ||
60
+
!session.password
61
+
) {
46
62
return null;
47
63
}
48
64
···
76
92
}
77
93
}
78
94
95
+
/**
96
+
* Set the credential session for the given request.
97
+
* @param req - The request object
98
+
* @param res - The response object
99
+
* @param data - The credential session data
100
+
* @param isMigration - Whether to set the migration session
101
+
* @returns The credential session
102
+
*/
79
103
export async function setCredentialSession(
80
104
req: Request,
81
105
res: Response,
82
106
data: CredentialSession,
83
-
isMigration: boolean = false,
107
+
isMigration: boolean = false
84
108
) {
85
-
const session = await getCredentialSession(
86
-
req,
87
-
res,
88
-
isMigration
89
-
);
109
+
const session = await getCredentialSession(req, res, isMigration);
90
110
session.did = data.did;
91
111
session.handle = data.handle;
92
112
session.service = data.service;
···
95
115
return session;
96
116
}
97
117
118
+
/**
119
+
* Get the credential session agent for the given request.
120
+
* @param req - The request object
121
+
* @param res - The response object
122
+
* @param isMigration - Whether to get the migration session
123
+
* @returns The credential session agent
124
+
*/
98
125
export async function getCredentialSessionAgent(
99
126
req: Request,
100
127
res: Response = new Response(),
101
-
isMigration: boolean = false,
128
+
isMigration: boolean = false
102
129
) {
103
-
const session = await getCredentialSession(
104
-
req,
105
-
res,
106
-
isMigration
107
-
);
130
+
const session = await getCredentialSession(req, res, isMigration);
108
131
109
132
console.log("Session state:", {
110
133
hasDid: !!session.did,
···
113
136
hasPassword: !!session.password,
114
137
hasAccessJwt: !!session.accessJwt,
115
138
service: session.service,
116
-
handle: session.handle
139
+
handle: session.handle,
117
140
});
118
141
119
142
if (
120
-
!session.did || !session.service || !session.handle || !session.password
143
+
!session.did ||
144
+
!session.service ||
145
+
!session.handle ||
146
+
!session.password
121
147
) {
122
148
console.log("Missing required session fields");
123
149
return null;
···
136
162
const sessionInfo = await agent.com.atproto.server.getSession();
137
163
console.log("Stored JWT is valid, session info:", {
138
164
did: sessionInfo.data.did,
139
-
handle: sessionInfo.data.handle
165
+
handle: sessionInfo.data.handle,
140
166
});
141
167
return agent;
142
168
} catch (err) {
···
156
182
console.log("Session created successfully:", {
157
183
did: sessionRes.data.did,
158
184
handle: sessionRes.data.handle,
159
-
hasAccessJwt: !!sessionRes.data.accessJwt
185
+
hasAccessJwt: !!sessionRes.data.accessJwt,
160
186
});
161
187
162
188
// Store the new token
+34
-1
lib/id-resolver.ts
+34
-1
lib/id-resolver.ts
···
8
8
pds: string;
9
9
}
10
10
11
+
interface DidService {
12
+
id: string;
13
+
type: string;
14
+
serviceEndpoint: string;
15
+
}
16
+
17
+
/**
18
+
* ID resolver instance.
19
+
*/
11
20
const idResolver = createIdResolver();
12
21
export const resolver = createBidirectionalResolver(idResolver);
13
22
23
+
/**
24
+
* Create the ID resolver.
25
+
* @returns The ID resolver
26
+
*/
14
27
export function createIdResolver() {
15
28
return new IdResolver();
16
29
}
17
30
31
+
/**
32
+
* The bidirectional resolver.
33
+
* @interface
34
+
*/
18
35
export interface BidirectionalResolver {
19
36
resolveDidToHandle(did: string): Promise<string>;
20
37
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>;
21
38
resolveDidToPdsUrl(did: string): Promise<string | undefined>;
22
39
}
23
40
41
+
/**
42
+
* Create the bidirectional resolver.
43
+
* @param resolver - The ID resolver
44
+
* @returns The bidirectional resolver
45
+
*/
24
46
export function createBidirectionalResolver(resolver: IdResolver) {
25
47
return {
26
48
async resolveDidToHandle(did: string): Promise<string> {
···
38
60
39
61
async resolveDidToPdsUrl(did: string): Promise<string | undefined> {
40
62
try {
63
+
// First try the standard resolution
41
64
const didDoc = await resolver.did.resolveAtprotoData(
42
65
did,
43
66
) as AtprotoData;
44
-
return didDoc.pds;
67
+
if (didDoc.pds) {
68
+
return didDoc.pds;
69
+
} else {
70
+
const forcedDidDoc = await resolver.did.resolveAtprotoData(
71
+
did,
72
+
true,
73
+
)
74
+
if (forcedDidDoc.pds) {
75
+
return forcedDidDoc.pds;
76
+
}
77
+
}
45
78
} catch (err) {
46
79
console.error("Error resolving PDS URL:", err);
47
80
return undefined;
+70
lib/migration-state.ts
+70
lib/migration-state.ts
···
1
+
/**
2
+
* Migration state types and utilities for controlling migration availability.
3
+
*/
4
+
5
+
export type MigrationState = "up" | "issue" | "maintenance";
6
+
7
+
export interface MigrationStateInfo {
8
+
state: MigrationState;
9
+
message: string;
10
+
allowMigration: boolean;
11
+
}
12
+
13
+
/**
14
+
* Get the current migration state from environment variables.
15
+
* @returns The migration state information
16
+
*/
17
+
export function getMigrationState(): MigrationStateInfo {
18
+
const state = (Deno.env.get("MIGRATION_STATE") || "up").toLowerCase() as MigrationState;
19
+
20
+
switch (state) {
21
+
case "issue":
22
+
return {
23
+
state: "issue",
24
+
message: "Migration services are temporarily unavailable as we investigate an issue. Please try again later.",
25
+
allowMigration: false,
26
+
};
27
+
28
+
case "maintenance":
29
+
return {
30
+
state: "maintenance",
31
+
message: "Migration services are temporarily unavailable for maintenance. Please try again later.",
32
+
allowMigration: false,
33
+
};
34
+
35
+
case "up":
36
+
default:
37
+
return {
38
+
state: "up",
39
+
message: "Migration services are operational.",
40
+
allowMigration: true,
41
+
};
42
+
}
43
+
}
44
+
45
+
/**
46
+
* Check if migrations are currently allowed.
47
+
* @returns True if migrations are allowed, false otherwise
48
+
*/
49
+
export function isMigrationAllowed(): boolean {
50
+
return getMigrationState().allowMigration;
51
+
}
52
+
53
+
/**
54
+
* Get a user-friendly message for the current migration state.
55
+
* @returns The message to display to users
56
+
*/
57
+
export function getMigrationStateMessage(): string {
58
+
return getMigrationState().message;
59
+
}
60
+
61
+
/**
62
+
* Throw an error if migrations are not allowed.
63
+
* Used in API endpoints to prevent migration operations when disabled.
64
+
*/
65
+
export function assertMigrationAllowed(): void {
66
+
const stateInfo = getMigrationState();
67
+
if (!stateInfo.allowMigration) {
68
+
throw new Error(stateInfo.message);
69
+
}
70
+
}
+5
lib/oauth/client.ts
+5
lib/oauth/client.ts
···
1
1
import { AtprotoOAuthClient } from "@bigmoves/atproto-oauth-client";
2
2
import { SessionStore, StateStore } from "../storage.ts";
3
3
4
+
/**
5
+
* Create the OAuth client.
6
+
* @param db - The Deno KV instance for the database
7
+
* @returns The OAuth client
8
+
*/
4
9
export const createClient = (db: Deno.Kv) => {
5
10
if (Deno.env.get("NODE_ENV") == "production" && !Deno.env.get("PUBLIC_URL")) {
6
11
throw new Error("PUBLIC_URL is not set");
+15
lib/oauth/sessions.ts
+15
lib/oauth/sessions.ts
···
5
5
6
6
let oauthSessionOptions: SessionOptions;
7
7
8
+
/**
9
+
* Get the OAuth session options.
10
+
* @returns The OAuth session options
11
+
*/
8
12
async function getOptions() {
9
13
if (!oauthSessionOptions) {
10
14
oauthSessionOptions = await createSessionOptions("oauth_sid");
···
12
16
return oauthSessionOptions;
13
17
}
14
18
19
+
/**
20
+
* Get the OAuth session agent for the given request.
21
+
* @param req - The request object
22
+
* @returns The OAuth session agent
23
+
*/
15
24
export async function getOauthSessionAgent(
16
25
req: Request
17
26
) {
···
47
56
}
48
57
}
49
58
59
+
/**
60
+
* Get the OAuth session for the given request.
61
+
* @param req - The request object
62
+
* @param res - The response object
63
+
* @returns The OAuth session
64
+
*/
50
65
export async function getOauthSession(
51
66
req: Request,
52
67
res: Response = new Response(),
+49
-10
lib/sessions.ts
+49
-10
lib/sessions.ts
···
1
1
import { Agent } from "npm:@atproto/api";
2
-
import { OauthSession, CredentialSession } from "./types.ts";
3
-
import { getCredentialSession, getCredentialSessionAgent } from "./cred/sessions.ts";
2
+
import { CredentialSession, OauthSession } from "./types.ts";
3
+
import {
4
+
getCredentialSession,
5
+
getCredentialSessionAgent,
6
+
} from "./cred/sessions.ts";
4
7
import { getOauthSession, getOauthSessionAgent } from "./oauth/sessions.ts";
5
8
import { IronSession } from "npm:iron-session";
6
9
10
+
/**
11
+
* Get the session for the given request.
12
+
* @param req - The request object
13
+
* @param res - The response object
14
+
* @param isMigration - Whether to get the migration session
15
+
* @returns The session
16
+
*/
7
17
export async function getSession(
8
18
req: Request,
9
19
res: Response = new Response(),
10
-
isMigration: boolean = false
20
+
isMigration: boolean = false,
11
21
): Promise<IronSession<OauthSession | CredentialSession>> {
12
22
if (isMigration) {
13
23
return await getCredentialSession(req, res, true);
···
16
26
const credentialSession = await getCredentialSession(req, res);
17
27
18
28
if (oauthSession.did) {
19
-
console.log("Oauth session found")
29
+
console.log("Oauth session found");
20
30
return oauthSession;
21
31
}
22
32
if (credentialSession.did) {
···
26
36
throw new Error("No session found");
27
37
}
28
38
39
+
/**
40
+
* Get the session agent for the given request.
41
+
* @param req - The request object
42
+
* @param res - The response object
43
+
* @param isMigration - Whether to get the migration session
44
+
* @returns The session agent
45
+
*/
29
46
export async function getSessionAgent(
30
47
req: Request,
31
48
res: Response = new Response(),
32
-
isMigration: boolean = false
49
+
isMigration: boolean = false,
33
50
): Promise<Agent | null> {
34
51
if (isMigration) {
35
52
return await getCredentialSessionAgent(req, res, isMigration);
36
53
}
37
54
38
55
const oauthAgent = await getOauthSessionAgent(req);
39
-
const credentialAgent = await getCredentialSessionAgent(req, res, isMigration);
56
+
const credentialAgent = await getCredentialSessionAgent(
57
+
req,
58
+
res,
59
+
isMigration,
60
+
);
40
61
41
62
if (oauthAgent) {
42
63
return oauthAgent;
···
49
70
return null;
50
71
}
51
72
52
-
export async function destroyAllSessions(req: Request) {
53
-
const oauthSession = await getOauthSession(req);
54
-
const credentialSession = await getCredentialSession(req);
55
-
const migrationSession = await getCredentialSession(req, new Response(), true);
73
+
/**
74
+
* Destroy all sessions for the given request.
75
+
* @param req - The request object
76
+
* @param res - The response object
77
+
*/
78
+
export async function destroyAllSessions(
79
+
req: Request,
80
+
res?: Response,
81
+
): Promise<Response> {
82
+
const response = res || new Response();
83
+
const oauthSession = await getOauthSession(req, response);
84
+
const credentialSession = await getCredentialSession(req, res);
85
+
const migrationSession = await getCredentialSession(
86
+
req,
87
+
res,
88
+
true,
89
+
);
56
90
57
91
if (oauthSession.did) {
58
92
oauthSession.destroy();
···
61
95
credentialSession.destroy();
62
96
}
63
97
if (migrationSession.did) {
98
+
console.log("DESTROYING MIGRATION SESSION", migrationSession);
64
99
migrationSession.destroy();
100
+
} else {
101
+
console.log("MIGRATION SESSION NOT FOUND", migrationSession);
65
102
}
103
+
104
+
return response;
66
105
}
+9
-1
lib/storage.ts
+9
-1
lib/storage.ts
···
3
3
NodeSavedSessionStore,
4
4
NodeSavedState,
5
5
NodeSavedStateStore,
6
-
} from "jsr:@bigmoves/atproto-oauth-client";
6
+
} from "@bigmoves/atproto-oauth-client";
7
7
8
+
/**
9
+
* The state store for sessions.
10
+
* @implements {NodeSavedStateStore}
11
+
*/
8
12
export class StateStore implements NodeSavedStateStore {
9
13
constructor(private db: Deno.Kv) {}
10
14
async get(key: string): Promise<NodeSavedState | undefined> {
···
19
23
}
20
24
}
21
25
26
+
/**
27
+
* The session store for sessions.
28
+
* @implements {NodeSavedSessionStore}
29
+
*/
22
30
export class SessionStore implements NodeSavedSessionStore {
23
31
constructor(private db: Deno.Kv) {}
24
32
async get(key: string): Promise<NodeSavedSession | undefined> {
+24
-1
lib/types.ts
+24
-1
lib/types.ts
···
1
1
import { SessionOptions as BaseSessionOptions } from "npm:iron-session";
2
2
3
+
/**
4
+
* The session options.
5
+
* @type {SessionOptions}
6
+
* @implements {BaseSessionOptions}
7
+
*/
3
8
interface SessionOptions extends BaseSessionOptions {
4
9
lockFn?: (key: string) => Promise<() => Promise<void>>;
5
10
}
6
11
7
-
// Helper function to create a lock using Deno KV
12
+
/**
13
+
* Create a lock using Deno KV.
14
+
* @param key - The key to lock
15
+
* @param db - The Deno KV instance for the database
16
+
* @returns The unlock function
17
+
*/
8
18
async function createLock(key: string, db: Deno.Kv): Promise<() => Promise<void>> {
9
19
const lockKey = ["session_lock", key];
10
20
const lockValue = Date.now();
···
25
35
};
26
36
}
27
37
38
+
/**
39
+
* The OAuth session.
40
+
* @type {OauthSession}
41
+
*/
28
42
export interface OauthSession {
29
43
did: string
30
44
}
31
45
46
+
/**
47
+
* The credential session.
48
+
* @type {CredentialSession}
49
+
*/
32
50
export interface CredentialSession {
33
51
did: string;
34
52
handle: string;
···
45
63
46
64
let db: Deno.Kv;
47
65
66
+
/**
67
+
* Create the session options.
68
+
* @param cookieName - The name of the iron session cookie
69
+
* @returns The session options for iron session
70
+
*/
48
71
export const createSessionOptions = async (cookieName: string): Promise<SessionOptions> => {
49
72
const cookieSecret = Deno.env.get("COOKIE_SECRET");
50
73
if (!cookieSecret) {
+104
routes/about.tsx
+104
routes/about.tsx
···
1
+
import { Button } from "../components/Button.tsx";
2
+
3
+
export default function About() {
4
+
return (
5
+
<>
6
+
<div class="px-2 sm:px-4 py-4 sm:py-8 mx-auto">
7
+
<div class="max-w-screen-lg mx-auto flex flex-col items-center justify-center">
8
+
<div class="prose dark:prose-invert max-w-none w-full mb-0">
9
+
<h1 class="text-3xl font-bold text-center mb-8">About AT Protocol</h1>
10
+
11
+
<div class="space-y-6">
12
+
<section>
13
+
<h2 class="text-2xl font-semibold mb-4">What is AT Protocol?</h2>
14
+
<p class="text-gray-600 dark:text-gray-300">
15
+
AT Protocol (Authenticated Transfer Protocol) is the
16
+
foundation of Bluesky and other social apps like
17
+
<a href="https://tangled.sh">Tangled</a>,
18
+
<a href="https://spark.com">Spark</a>, and more.
19
+
Unlike traditional social platforms that lock your
20
+
data and identity to a single service, AT Protocol
21
+
gives you complete control over your digital presence.
22
+
Think of it as an open standard for social networking,
23
+
similar to how email works across different providers.
24
+
</p>
25
+
</section>
26
+
27
+
<section>
28
+
<h2 class="text-2xl font-semibold mb-4">Key Features</h2>
29
+
<ul class="list-disc pl-6 space-y-4 text-gray-600 dark:text-gray-300">
30
+
<li>
31
+
<strong>PDS Servers:</strong> PDS servers are where your data is stored.
32
+
They can be run by anyone, and they are very lightweight, allowing you to
33
+
choose which one to use or run your own. PDS servers just store your data,
34
+
meaning you don't have to switch PDS servers to use a different app or service.
35
+
You can have one PDS while using many different apps and services with the
36
+
same account.
37
+
</li>
38
+
<li>
39
+
<strong>Decentralized Identity:</strong> Your account is tied to a DID
40
+
(Decentralized Identifier) rather than your handle/username.
41
+
This means you can move your entire account, including your followers
42
+
and content, to any PDS by changing where your DID points.
43
+
It's also the reason you can use any domain as your handle, because
44
+
your identity is not tied to your handle. Your handle can change,
45
+
but your DID will always remain the same.
46
+
</li>
47
+
<li>
48
+
<strong>Portable Content:</strong> All your posts, likes, and other social
49
+
data are stored in your Personal Data Server (PDS).
50
+
You can switch PDS providers without losing any content or connections.
51
+
</li>
52
+
<li>
53
+
<strong>Architecture:</strong> The protocol uses a three-tier architecture:
54
+
Personal Data Servers (PDS) store your content,
55
+
relays broadcast a stream of all events on all PDSes,
56
+
and AppViews process and serve that stream into content for users.
57
+
This means when you make a post, the content is stored on your PDS,
58
+
picked up by relays, and AppViews listen to those relays to deliver
59
+
that post to all users.
60
+
</li>
61
+
<li>
62
+
<strong>Algorithmic Choice:</strong> You're not locked into a single algorithm
63
+
for your feed. Different services can offer different ways of curating content,
64
+
and you can choose which one you prefer. Bluesky offers a way to make custom
65
+
feeds, but even if it didn't, different apps could still offer their own
66
+
algorithms for curating content.
67
+
</li>
68
+
</ul>
69
+
</section>
70
+
71
+
<section>
72
+
<h2 class="text-2xl font-semibold mb-4">Learn More</h2>
73
+
<div class="space-y-4">
74
+
<p class="text-gray-600 dark:text-gray-300">
75
+
Want to dive deeper into AT Protocol? Check out these resources:
76
+
</p>
77
+
<ul class="list-none space-y-2">
78
+
<li>
79
+
<a href="https://atproto.com" class="text-blue-500 hover:underline">Official AT Protocol Docs</a> - The main source for protocol specs and information
80
+
</li>
81
+
<li>
82
+
<a href="https://github.com/bluesky-social/atproto" class="text-blue-500 hover:underline">GitHub Repository</a> - View the protocol implementation
83
+
</li>
84
+
<li>
85
+
<a href="https://atproto.wiki" class="text-blue-500 hover:underline">AT Protocol Wiki</a> - Community-driven documentation and resources
86
+
</li>
87
+
</ul>
88
+
</div>
89
+
</section>
90
+
</div>
91
+
92
+
<div class="mt-8 text-center">
93
+
<Button
94
+
href="/"
95
+
color="blue"
96
+
label="Back to Home"
97
+
/>
98
+
</div>
99
+
</div>
100
+
</div>
101
+
</div>
102
+
</>
103
+
);
104
+
}
+7
routes/api/cred/login.ts
+7
routes/api/cred/login.ts
···
3
3
import { define } from "../../../utils.ts";
4
4
import { Agent } from "npm:@atproto/api";
5
5
6
+
/**
7
+
* Handle credential login
8
+
* Save iron session to cookies
9
+
* Save credential session state to database
10
+
* @param ctx - The context object containing the request and response
11
+
* @returns A response object with the login result
12
+
*/
6
13
export const handler = define.handlers({
7
14
async POST(ctx) {
8
15
try {
+6
-3
routes/api/logout.ts
+6
-3
routes/api/logout.ts
···
1
-
import { getSession } from "../../lib/sessions.ts";
1
+
import { destroyAllSessions, getSession } from "../../lib/sessions.ts";
2
2
import { oauthClient } from "../../lib/oauth/client.ts";
3
3
import { define } from "../../utils.ts";
4
4
···
13
13
if (session.did) {
14
14
// Try to revoke both types of sessions - the one that doesn't exist will just no-op
15
15
await Promise.all([
16
-
oauthClient.revoke(session.did).catch(console.error)
16
+
oauthClient.revoke(session.did).catch(console.error),
17
17
]);
18
18
// Then destroy the iron session
19
19
session.destroy();
20
20
}
21
21
22
-
return response;
22
+
// Destroy all sessions including migration session
23
+
const result = await destroyAllSessions(req, response);
24
+
25
+
return result;
23
26
} catch (error: unknown) {
24
27
const err = error instanceof Error ? error : new Error(String(error));
25
28
console.error("Logout failed:", err.message);
+18
-2
routes/api/migrate/create.ts
+18
-2
routes/api/migrate/create.ts
···
2
2
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
3
3
import { Agent } from "@atproto/api";
4
4
import { define } from "../../../utils.ts";
5
+
import { assertMigrationAllowed } from "../../../lib/migration-state.ts";
5
6
7
+
/**
8
+
* Handle account creation
9
+
* First step of the migration process
10
+
* Body must contain:
11
+
* - service: The service URL of the new account
12
+
* - handle: The handle of the new account
13
+
* - password: The password of the new account
14
+
* - email: The email of the new account
15
+
* - invite: The invite code of the new account (optional depending on the PDS)
16
+
* @param ctx - The context object containing the request and response
17
+
* @returns A response object with the creation result
18
+
*/
6
19
export const handler = define.handlers({
7
20
async POST(ctx) {
8
21
const res = new Response();
9
22
try {
23
+
// Check if migrations are currently allowed
24
+
assertMigrationAllowed();
25
+
10
26
const body = await ctx.req.json();
11
27
const serviceUrl = body.service;
12
28
const newHandle = body.handle;
···
29
45
return new Response("Could not create new agent", { status: 400 });
30
46
}
31
47
32
-
console.log("getting did")
48
+
console.log("getting did");
33
49
const session = await oldAgent.com.atproto.server.getSession();
34
50
const accountDid = session.data.did;
35
-
console.log("got did")
51
+
console.log("got did");
36
52
const describeRes = await newAgent.com.atproto.server.describeServer();
37
53
const newServerDid = describeRes.data.did;
38
54
const inviteRequired = describeRes.data.inviteCodeRequired ?? false;
+359
routes/api/migrate/data/blobs.ts
+359
routes/api/migrate/data/blobs.ts
···
1
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
2
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
3
+
import { define } from "../../../../utils.ts";
4
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
5
+
6
+
export const handler = define.handlers({
7
+
async POST(ctx) {
8
+
const res = new Response();
9
+
try {
10
+
// Check if migrations are currently allowed
11
+
assertMigrationAllowed();
12
+
13
+
console.log("Blob migration: Starting session retrieval");
14
+
const oldAgent = await getSessionAgent(ctx.req);
15
+
console.log("Blob migration: Got old agent:", !!oldAgent);
16
+
17
+
const newAgent = await getSessionAgent(ctx.req, res, true);
18
+
console.log("Blob migration: Got new agent:", !!newAgent);
19
+
20
+
if (!oldAgent) {
21
+
return new Response(
22
+
JSON.stringify({
23
+
success: false,
24
+
message: "Unauthorized",
25
+
}),
26
+
{
27
+
status: 401,
28
+
headers: { "Content-Type": "application/json" },
29
+
},
30
+
);
31
+
}
32
+
if (!newAgent) {
33
+
return new Response(
34
+
JSON.stringify({
35
+
success: false,
36
+
message: "Migration session not found or invalid",
37
+
}),
38
+
{
39
+
status: 400,
40
+
headers: { "Content-Type": "application/json" },
41
+
},
42
+
);
43
+
}
44
+
45
+
// Verify DIDs match between sessions
46
+
const didsMatch = await checkDidsMatch(ctx.req);
47
+
if (!didsMatch) {
48
+
return new Response(
49
+
JSON.stringify({
50
+
success: false,
51
+
message: "Invalid state, original and target DIDs do not match",
52
+
}),
53
+
{
54
+
status: 400,
55
+
headers: { "Content-Type": "application/json" },
56
+
},
57
+
);
58
+
}
59
+
60
+
// Migrate blobs
61
+
const migrationLogs: string[] = [];
62
+
const migratedBlobs: string[] = [];
63
+
const failedBlobs: string[] = [];
64
+
let pageCount = 0;
65
+
let blobCursor: string | undefined = undefined;
66
+
let totalBlobs = 0;
67
+
let processedBlobs = 0;
68
+
69
+
const startTime = Date.now();
70
+
console.log(`[${new Date().toISOString()}] Starting blob migration...`);
71
+
migrationLogs.push(
72
+
`[${new Date().toISOString()}] Starting blob migration...`,
73
+
);
74
+
75
+
// First count total blobs
76
+
console.log(`[${new Date().toISOString()}] Starting blob count...`);
77
+
migrationLogs.push(
78
+
`[${new Date().toISOString()}] Starting blob count...`,
79
+
);
80
+
81
+
const session = await oldAgent.com.atproto.server.getSession();
82
+
const accountDid = session.data.did;
83
+
84
+
do {
85
+
const pageStartTime = Date.now();
86
+
console.log(
87
+
`[${new Date().toISOString()}] Counting blobs on page ${
88
+
pageCount + 1
89
+
}...`,
90
+
);
91
+
migrationLogs.push(
92
+
`[${new Date().toISOString()}] Counting blobs on page ${
93
+
pageCount + 1
94
+
}...`,
95
+
);
96
+
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
97
+
did: accountDid,
98
+
cursor: blobCursor,
99
+
});
100
+
101
+
const newBlobs = listedBlobs.data.cids.length;
102
+
totalBlobs += newBlobs;
103
+
const pageTime = Date.now() - pageStartTime;
104
+
105
+
console.log(
106
+
`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${
107
+
pageCount + 1
108
+
} in ${pageTime / 1000} seconds, total so far: ${totalBlobs}`,
109
+
);
110
+
migrationLogs.push(
111
+
`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${
112
+
pageCount + 1
113
+
} in ${pageTime / 1000} seconds, total so far: ${totalBlobs}`,
114
+
);
115
+
116
+
pageCount++;
117
+
blobCursor = listedBlobs.data.cursor;
118
+
} while (blobCursor);
119
+
120
+
console.log(
121
+
`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`,
122
+
);
123
+
migrationLogs.push(
124
+
`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`,
125
+
);
126
+
127
+
// Reset cursor for actual migration
128
+
blobCursor = undefined;
129
+
pageCount = 0;
130
+
processedBlobs = 0;
131
+
132
+
do {
133
+
const pageStartTime = Date.now();
134
+
console.log(
135
+
`[${new Date().toISOString()}] Fetching blob list page ${
136
+
pageCount + 1
137
+
}...`,
138
+
);
139
+
migrationLogs.push(
140
+
`[${new Date().toISOString()}] Fetching blob list page ${
141
+
pageCount + 1
142
+
}...`,
143
+
);
144
+
145
+
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
146
+
did: accountDid,
147
+
cursor: blobCursor,
148
+
});
149
+
150
+
const pageTime = Date.now() - pageStartTime;
151
+
console.log(
152
+
`[${
153
+
new Date().toISOString()
154
+
}] Found ${listedBlobs.data.cids.length} blobs on page ${
155
+
pageCount + 1
156
+
} in ${pageTime / 1000} seconds`,
157
+
);
158
+
migrationLogs.push(
159
+
`[${
160
+
new Date().toISOString()
161
+
}] Found ${listedBlobs.data.cids.length} blobs on page ${
162
+
pageCount + 1
163
+
} in ${pageTime / 1000} seconds`,
164
+
);
165
+
166
+
blobCursor = listedBlobs.data.cursor;
167
+
168
+
for (const cid of listedBlobs.data.cids) {
169
+
try {
170
+
const blobStartTime = Date.now();
171
+
console.log(
172
+
`[${
173
+
new Date().toISOString()
174
+
}] Starting migration for blob ${cid} (${
175
+
processedBlobs + 1
176
+
} of ${totalBlobs})...`,
177
+
);
178
+
migrationLogs.push(
179
+
`[${
180
+
new Date().toISOString()
181
+
}] Starting migration for blob ${cid} (${
182
+
processedBlobs + 1
183
+
} of ${totalBlobs})...`,
184
+
);
185
+
186
+
const blobRes = await oldAgent.com.atproto.sync.getBlob({
187
+
did: accountDid,
188
+
cid,
189
+
});
190
+
191
+
const contentLength = blobRes.headers["content-length"];
192
+
if (!contentLength) {
193
+
throw new Error(`Blob ${cid} has no content length`);
194
+
}
195
+
196
+
const size = parseInt(contentLength, 10);
197
+
if (isNaN(size)) {
198
+
throw new Error(
199
+
`Blob ${cid} has invalid content length: ${contentLength}`,
200
+
);
201
+
}
202
+
203
+
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
204
+
if (size > MAX_SIZE) {
205
+
throw new Error(
206
+
`Blob ${cid} exceeds maximum size limit (${size} bytes)`,
207
+
);
208
+
}
209
+
210
+
console.log(
211
+
`[${
212
+
new Date().toISOString()
213
+
}] Downloading blob ${cid} (${size} bytes)...`,
214
+
);
215
+
migrationLogs.push(
216
+
`[${
217
+
new Date().toISOString()
218
+
}] Downloading blob ${cid} (${size} bytes)...`,
219
+
);
220
+
221
+
if (!blobRes.data) {
222
+
throw new Error(
223
+
`Failed to download blob ${cid}: No data received`,
224
+
);
225
+
}
226
+
227
+
console.log(
228
+
`[${
229
+
new Date().toISOString()
230
+
}] Uploading blob ${cid} to new account...`,
231
+
);
232
+
migrationLogs.push(
233
+
`[${
234
+
new Date().toISOString()
235
+
}] Uploading blob ${cid} to new account...`,
236
+
);
237
+
238
+
try {
239
+
await newAgent.com.atproto.repo.uploadBlob(blobRes.data);
240
+
const blobTime = Date.now() - blobStartTime;
241
+
console.log(
242
+
`[${
243
+
new Date().toISOString()
244
+
}] Successfully migrated blob ${cid} in ${
245
+
blobTime / 1000
246
+
} seconds`,
247
+
);
248
+
migrationLogs.push(
249
+
`[${
250
+
new Date().toISOString()
251
+
}] Successfully migrated blob ${cid} in ${
252
+
blobTime / 1000
253
+
} seconds`,
254
+
);
255
+
migratedBlobs.push(cid);
256
+
} catch (uploadError) {
257
+
console.error(
258
+
`[${new Date().toISOString()}] Failed to upload blob ${cid}:`,
259
+
uploadError,
260
+
);
261
+
throw new Error(
262
+
`Upload failed: ${
263
+
uploadError instanceof Error
264
+
? uploadError.message
265
+
: String(uploadError)
266
+
}`,
267
+
);
268
+
}
269
+
} catch (error) {
270
+
const errorMessage = error instanceof Error
271
+
? error.message
272
+
: String(error);
273
+
const detailedError = `[${
274
+
new Date().toISOString()
275
+
}] Failed to migrate blob ${cid}: ${errorMessage}`;
276
+
console.error(detailedError);
277
+
console.error("Full error details:", error);
278
+
migrationLogs.push(detailedError);
279
+
failedBlobs.push(cid);
280
+
}
281
+
282
+
processedBlobs++;
283
+
const progressLog = `[${
284
+
new Date().toISOString()
285
+
}] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${
286
+
Math.round((processedBlobs / totalBlobs) * 100)
287
+
}%)`;
288
+
console.log(progressLog);
289
+
migrationLogs.push(progressLog);
290
+
}
291
+
pageCount++;
292
+
} while (blobCursor);
293
+
294
+
const totalTime = Date.now() - startTime;
295
+
const completionMessage = `[${
296
+
new Date().toISOString()
297
+
}] Blob migration completed in ${
298
+
totalTime / 1000
299
+
} seconds: ${migratedBlobs.length} blobs migrated${
300
+
failedBlobs.length > 0 ? `, ${failedBlobs.length} failed` : ""
301
+
} (${pageCount} pages processed)`;
302
+
console.log(completionMessage);
303
+
migrationLogs.push(completionMessage);
304
+
305
+
return new Response(
306
+
JSON.stringify({
307
+
success: true,
308
+
message: failedBlobs.length > 0
309
+
? `Blob migration completed with ${failedBlobs.length} failed blobs`
310
+
: "Blob migration completed successfully",
311
+
migratedBlobs,
312
+
failedBlobs,
313
+
totalMigrated: migratedBlobs.length,
314
+
totalFailed: failedBlobs.length,
315
+
totalProcessed: processedBlobs,
316
+
totalBlobs,
317
+
logs: migrationLogs,
318
+
timing: {
319
+
totalTime: totalTime / 1000,
320
+
},
321
+
}),
322
+
{
323
+
status: 200,
324
+
headers: {
325
+
"Content-Type": "application/json",
326
+
...Object.fromEntries(res.headers),
327
+
},
328
+
},
329
+
);
330
+
} catch (error) {
331
+
const message = error instanceof Error ? error.message : String(error);
332
+
console.error(
333
+
`[${new Date().toISOString()}] Blob migration error:`,
334
+
message,
335
+
);
336
+
console.error("Full error details:", error);
337
+
return new Response(
338
+
JSON.stringify({
339
+
success: false,
340
+
message: `Blob migration failed: ${message}`,
341
+
error: error instanceof Error
342
+
? {
343
+
name: error.name,
344
+
message: error.message,
345
+
stack: error.stack,
346
+
}
347
+
: String(error),
348
+
}),
349
+
{
350
+
status: 500,
351
+
headers: {
352
+
"Content-Type": "application/json",
353
+
...Object.fromEntries(res.headers),
354
+
},
355
+
},
356
+
);
357
+
}
358
+
},
359
+
});
+163
routes/api/migrate/data/prefs.ts
+163
routes/api/migrate/data/prefs.ts
···
1
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
2
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
3
+
import { define } from "../../../../utils.ts";
4
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
5
+
6
+
export const handler = define.handlers({
7
+
async POST(ctx) {
8
+
const res = new Response();
9
+
try {
10
+
// Check if migrations are currently allowed
11
+
assertMigrationAllowed();
12
+
13
+
console.log("Preferences migration: Starting session retrieval");
14
+
const oldAgent = await getSessionAgent(ctx.req);
15
+
console.log("Preferences migration: Got old agent:", !!oldAgent);
16
+
17
+
const newAgent = await getSessionAgent(ctx.req, res, true);
18
+
console.log("Preferences migration: Got new agent:", !!newAgent);
19
+
20
+
if (!oldAgent || !newAgent) {
21
+
return new Response(
22
+
JSON.stringify({
23
+
success: false,
24
+
message: "Not authenticated",
25
+
}),
26
+
{
27
+
status: 401,
28
+
headers: { "Content-Type": "application/json" },
29
+
},
30
+
);
31
+
}
32
+
33
+
// Verify DIDs match between sessions
34
+
const didsMatch = await checkDidsMatch(ctx.req);
35
+
if (!didsMatch) {
36
+
return new Response(
37
+
JSON.stringify({
38
+
success: false,
39
+
message: "Invalid state, original and target DIDs do not match",
40
+
}),
41
+
{
42
+
status: 400,
43
+
headers: { "Content-Type": "application/json" },
44
+
},
45
+
);
46
+
}
47
+
48
+
// Migrate preferences
49
+
const migrationLogs: string[] = [];
50
+
const startTime = Date.now();
51
+
console.log(
52
+
`[${new Date().toISOString()}] Starting preferences migration...`,
53
+
);
54
+
migrationLogs.push(
55
+
`[${new Date().toISOString()}] Starting preferences migration...`,
56
+
);
57
+
58
+
// Fetch preferences
59
+
console.log(
60
+
`[${
61
+
new Date().toISOString()
62
+
}] Fetching preferences from old account...`,
63
+
);
64
+
migrationLogs.push(
65
+
`[${
66
+
new Date().toISOString()
67
+
}] Fetching preferences from old account...`,
68
+
);
69
+
70
+
const fetchStartTime = Date.now();
71
+
const prefs = await oldAgent.app.bsky.actor.getPreferences();
72
+
const fetchTime = Date.now() - fetchStartTime;
73
+
74
+
console.log(
75
+
`[${new Date().toISOString()}] Preferences fetched in ${
76
+
fetchTime / 1000
77
+
} seconds`,
78
+
);
79
+
migrationLogs.push(
80
+
`[${new Date().toISOString()}] Preferences fetched in ${
81
+
fetchTime / 1000
82
+
} seconds`,
83
+
);
84
+
85
+
// Update preferences
86
+
console.log(
87
+
`[${new Date().toISOString()}] Updating preferences on new account...`,
88
+
);
89
+
migrationLogs.push(
90
+
`[${new Date().toISOString()}] Updating preferences on new account...`,
91
+
);
92
+
93
+
const updateStartTime = Date.now();
94
+
await newAgent.app.bsky.actor.putPreferences(prefs.data);
95
+
const updateTime = Date.now() - updateStartTime;
96
+
97
+
console.log(
98
+
`[${new Date().toISOString()}] Preferences updated in ${
99
+
updateTime / 1000
100
+
} seconds`,
101
+
);
102
+
migrationLogs.push(
103
+
`[${new Date().toISOString()}] Preferences updated in ${
104
+
updateTime / 1000
105
+
} seconds`,
106
+
);
107
+
108
+
const totalTime = Date.now() - startTime;
109
+
const completionMessage = `[${
110
+
new Date().toISOString()
111
+
}] Preferences migration completed in ${totalTime / 1000} seconds total`;
112
+
console.log(completionMessage);
113
+
migrationLogs.push(completionMessage);
114
+
115
+
return new Response(
116
+
JSON.stringify({
117
+
success: true,
118
+
message: "Preferences migration completed successfully",
119
+
logs: migrationLogs,
120
+
timing: {
121
+
fetchTime: fetchTime / 1000,
122
+
updateTime: updateTime / 1000,
123
+
totalTime: totalTime / 1000,
124
+
},
125
+
}),
126
+
{
127
+
status: 200,
128
+
headers: {
129
+
"Content-Type": "application/json",
130
+
...Object.fromEntries(res.headers),
131
+
},
132
+
},
133
+
);
134
+
} catch (error) {
135
+
const message = error instanceof Error ? error.message : String(error);
136
+
console.error(
137
+
`[${new Date().toISOString()}] Preferences migration error:`,
138
+
message,
139
+
);
140
+
console.error("Full error details:", error);
141
+
return new Response(
142
+
JSON.stringify({
143
+
success: false,
144
+
message: `Preferences migration failed: ${message}`,
145
+
error: error instanceof Error
146
+
? {
147
+
name: error.name,
148
+
message: error.message,
149
+
stack: error.stack,
150
+
}
151
+
: String(error),
152
+
}),
153
+
{
154
+
status: 500,
155
+
headers: {
156
+
"Content-Type": "application/json",
157
+
...Object.fromEntries(res.headers),
158
+
},
159
+
},
160
+
);
161
+
}
162
+
},
163
+
});
+163
routes/api/migrate/data/repo.ts
+163
routes/api/migrate/data/repo.ts
···
1
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
2
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
3
+
import { define } from "../../../../utils.ts";
4
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
5
+
6
+
export const handler = define.handlers({
7
+
async POST(ctx) {
8
+
const res = new Response();
9
+
try {
10
+
// Check if migrations are currently allowed
11
+
assertMigrationAllowed();
12
+
13
+
console.log("Repo migration: Starting session retrieval");
14
+
const oldAgent = await getSessionAgent(ctx.req);
15
+
console.log("Repo migration: Got old agent:", !!oldAgent);
16
+
17
+
const newAgent = await getSessionAgent(ctx.req, res, true);
18
+
console.log("Repo migration: Got new agent:", !!newAgent);
19
+
20
+
if (!oldAgent || !newAgent) {
21
+
return new Response(
22
+
JSON.stringify({
23
+
success: false,
24
+
message: "Not authenticated",
25
+
}),
26
+
{
27
+
status: 401,
28
+
headers: { "Content-Type": "application/json" },
29
+
},
30
+
);
31
+
}
32
+
33
+
// Verify DIDs match between sessions
34
+
const didsMatch = await checkDidsMatch(ctx.req);
35
+
if (!didsMatch) {
36
+
return new Response(
37
+
JSON.stringify({
38
+
success: false,
39
+
message: "Invalid state, original and target DIDs do not match",
40
+
}),
41
+
{
42
+
status: 400,
43
+
headers: { "Content-Type": "application/json" },
44
+
},
45
+
);
46
+
}
47
+
48
+
const session = await oldAgent.com.atproto.server.getSession();
49
+
const accountDid = session.data.did;
50
+
// Migrate repo data
51
+
const migrationLogs: string[] = [];
52
+
const startTime = Date.now();
53
+
console.log(`[${new Date().toISOString()}] Starting repo migration...`);
54
+
migrationLogs.push(
55
+
`[${new Date().toISOString()}] Starting repo migration...`,
56
+
);
57
+
58
+
// Get repo data from old account
59
+
console.log(
60
+
`[${new Date().toISOString()}] Fetching repo data from old account...`,
61
+
);
62
+
migrationLogs.push(
63
+
`[${new Date().toISOString()}] Fetching repo data from old account...`,
64
+
);
65
+
66
+
const fetchStartTime = Date.now();
67
+
const repoData = await oldAgent.com.atproto.sync.getRepo({
68
+
did: accountDid,
69
+
});
70
+
const fetchTime = Date.now() - fetchStartTime;
71
+
72
+
console.log(
73
+
`[${new Date().toISOString()}] Repo data fetched in ${
74
+
fetchTime / 1000
75
+
} seconds`,
76
+
);
77
+
migrationLogs.push(
78
+
`[${new Date().toISOString()}] Repo data fetched in ${
79
+
fetchTime / 1000
80
+
} seconds`,
81
+
);
82
+
83
+
console.log(
84
+
`[${new Date().toISOString()}] Importing repo data to new account...`,
85
+
);
86
+
migrationLogs.push(
87
+
`[${new Date().toISOString()}] Importing repo data to new account...`,
88
+
);
89
+
90
+
// Import repo data to new account
91
+
const importStartTime = Date.now();
92
+
await newAgent.com.atproto.repo.importRepo(repoData.data, {
93
+
encoding: "application/vnd.ipld.car",
94
+
});
95
+
const importTime = Date.now() - importStartTime;
96
+
97
+
console.log(
98
+
`[${new Date().toISOString()}] Repo data imported in ${
99
+
importTime / 1000
100
+
} seconds`,
101
+
);
102
+
migrationLogs.push(
103
+
`[${new Date().toISOString()}] Repo data imported in ${
104
+
importTime / 1000
105
+
} seconds`,
106
+
);
107
+
108
+
const totalTime = Date.now() - startTime;
109
+
const completionMessage = `[${
110
+
new Date().toISOString()
111
+
}] Repo migration completed in ${totalTime / 1000} seconds total`;
112
+
console.log(completionMessage);
113
+
migrationLogs.push(completionMessage);
114
+
115
+
return new Response(
116
+
JSON.stringify({
117
+
success: true,
118
+
message: "Repo migration completed successfully",
119
+
logs: migrationLogs,
120
+
timing: {
121
+
fetchTime: fetchTime / 1000,
122
+
importTime: importTime / 1000,
123
+
totalTime: totalTime / 1000,
124
+
},
125
+
}),
126
+
{
127
+
status: 200,
128
+
headers: {
129
+
"Content-Type": "application/json",
130
+
...Object.fromEntries(res.headers),
131
+
},
132
+
},
133
+
);
134
+
} catch (error) {
135
+
const message = error instanceof Error ? error.message : String(error);
136
+
console.error(
137
+
`[${new Date().toISOString()}] Repo migration error:`,
138
+
message,
139
+
);
140
+
console.error("Full error details:", error);
141
+
return new Response(
142
+
JSON.stringify({
143
+
success: false,
144
+
message: `Repo migration failed: ${message}`,
145
+
error: error instanceof Error
146
+
? {
147
+
name: error.name,
148
+
message: error.message,
149
+
stack: error.stack,
150
+
}
151
+
: String(error),
152
+
}),
153
+
{
154
+
status: 500,
155
+
headers: {
156
+
"Content-Type": "application/json",
157
+
...Object.fromEntries(res.headers),
158
+
},
159
+
},
160
+
);
161
+
}
162
+
},
163
+
});
-273
routes/api/migrate/data.ts
-273
routes/api/migrate/data.ts
···
1
-
import { define } from "../../../utils.ts";
2
-
import {
3
-
getSessionAgent,
4
-
} from "../../../lib/sessions.ts";
5
-
import { Agent, ComAtprotoSyncGetBlob } from "npm:@atproto/api";
6
-
7
-
// Retry configuration
8
-
const MAX_RETRIES = 3;
9
-
const INITIAL_RETRY_DELAY = 1000; // 1 second
10
-
11
-
interface RetryOptions {
12
-
maxRetries?: number;
13
-
initialDelay?: number;
14
-
onRetry?: (attempt: number, error: Error) => void;
15
-
}
16
-
17
-
async function withRetry<T>(
18
-
operation: () => Promise<T>,
19
-
options: RetryOptions = {},
20
-
): Promise<T> {
21
-
const maxRetries = options.maxRetries ?? MAX_RETRIES;
22
-
const initialDelay = options.initialDelay ?? INITIAL_RETRY_DELAY;
23
-
24
-
let lastError: Error | null = null;
25
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
26
-
try {
27
-
return await operation();
28
-
} catch (error) {
29
-
lastError = error instanceof Error ? error : new Error(String(error));
30
-
31
-
// Don't retry on certain errors
32
-
if (error instanceof Error) {
33
-
// Don't retry on permanent errors like authentication
34
-
if (error.message.includes("Unauthorized") || error.message.includes("Invalid token")) {
35
-
throw error;
36
-
}
37
-
}
38
-
39
-
if (attempt < maxRetries - 1) {
40
-
const delay = initialDelay * Math.pow(2, attempt);
41
-
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms:`, lastError.message);
42
-
if (options.onRetry) {
43
-
options.onRetry(attempt + 1, lastError);
44
-
}
45
-
await new Promise(resolve => setTimeout(resolve, delay));
46
-
}
47
-
}
48
-
}
49
-
throw lastError ?? new Error("Operation failed after retries");
50
-
}
51
-
52
-
async function handleBlobUpload(
53
-
newAgent: Agent,
54
-
blobRes: ComAtprotoSyncGetBlob.Response,
55
-
cid: string
56
-
) {
57
-
try {
58
-
const contentLength = parseInt(blobRes.headers["content-length"] || "0", 10);
59
-
const contentType = blobRes.headers["content-type"];
60
-
61
-
// Check file size before attempting upload
62
-
const MAX_SIZE = 95 * 1024 * 1024; // 95MB to be safe
63
-
if (contentLength > MAX_SIZE) {
64
-
throw new Error(`Blob ${cid} exceeds maximum size limit (${contentLength} bytes)`);
65
-
}
66
-
67
-
await withRetry(
68
-
() => newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
69
-
encoding: contentType,
70
-
}),
71
-
{
72
-
maxRetries: 5,
73
-
onRetry: (attempt, error) => {
74
-
console.log(`Retrying blob upload for ${cid} (attempt ${attempt}):`, error.message);
75
-
},
76
-
}
77
-
);
78
-
} catch (error) {
79
-
console.error(`Failed to upload blob ${cid}:`, error);
80
-
throw error;
81
-
}
82
-
}
83
-
84
-
export const handler = define.handlers({
85
-
async POST(ctx) {
86
-
const res = new Response();
87
-
try {
88
-
console.log("Data migration: Starting session retrieval");
89
-
const oldAgent = await getSessionAgent(ctx.req);
90
-
console.log("Data migration: Got old agent:", !!oldAgent);
91
-
92
-
// Log cookie information
93
-
const cookies = ctx.req.headers.get("cookie");
94
-
console.log("Data migration: Cookies present:", !!cookies);
95
-
console.log("Data migration: Cookie header:", cookies);
96
-
97
-
const newAgent = await getSessionAgent(ctx.req, res, true);
98
-
console.log("Data migration: Got new agent:", !!newAgent);
99
-
100
-
if (!oldAgent) {
101
-
return new Response(
102
-
JSON.stringify({
103
-
success: false,
104
-
message: "Unauthorized",
105
-
}),
106
-
{
107
-
status: 401,
108
-
headers: { "Content-Type": "application/json" },
109
-
},
110
-
);
111
-
}
112
-
if (!newAgent) {
113
-
return new Response(
114
-
JSON.stringify({
115
-
success: false,
116
-
message: "Migration session not found or invalid",
117
-
}),
118
-
{
119
-
status: 400,
120
-
headers: { "Content-Type": "application/json" },
121
-
},
122
-
);
123
-
}
124
-
125
-
const session = await oldAgent.com.atproto.server.getSession();
126
-
const accountDid = session.data.did;
127
-
128
-
// Migrate repo data with retries
129
-
const repoRes = await withRetry(
130
-
() => oldAgent.com.atproto.sync.getRepo({
131
-
did: accountDid,
132
-
}),
133
-
{
134
-
maxRetries: 5,
135
-
onRetry: (attempt, error) => {
136
-
console.log(`Retrying repo fetch (attempt ${attempt}):`, error.message);
137
-
},
138
-
}
139
-
);
140
-
141
-
await withRetry(
142
-
() => newAgent.com.atproto.repo.importRepo(repoRes.data, {
143
-
encoding: "application/vnd.ipld.car",
144
-
}),
145
-
{
146
-
maxRetries: 5,
147
-
onRetry: (attempt, error) => {
148
-
console.log(`Retrying repo import (attempt ${attempt}):`, error.message);
149
-
},
150
-
}
151
-
);
152
-
153
-
// Migrate blobs with enhanced error handling
154
-
let blobCursor: string | undefined = undefined;
155
-
const migratedBlobs: string[] = [];
156
-
const failedBlobs: Array<{ cid: string; error: string }> = [];
157
-
158
-
do {
159
-
try {
160
-
const listedBlobs = await withRetry(
161
-
() => oldAgent.com.atproto.sync.listBlobs({
162
-
did: accountDid,
163
-
cursor: blobCursor,
164
-
}),
165
-
{
166
-
maxRetries: 5,
167
-
onRetry: (attempt, error) => {
168
-
console.log(`Retrying blob list fetch (attempt ${attempt}):`, error.message);
169
-
},
170
-
}
171
-
);
172
-
173
-
for (const cid of listedBlobs.data.cids) {
174
-
try {
175
-
const blobRes = await withRetry(
176
-
() => oldAgent.com.atproto.sync.getBlob({
177
-
did: accountDid,
178
-
cid,
179
-
}),
180
-
{
181
-
maxRetries: 5,
182
-
onRetry: (attempt, error) => {
183
-
console.log(`Retrying blob download for ${cid} (attempt ${attempt}):`, error.message);
184
-
},
185
-
}
186
-
);
187
-
188
-
await handleBlobUpload(newAgent, blobRes, cid);
189
-
migratedBlobs.push(cid);
190
-
console.log(`Successfully migrated blob: ${cid}`);
191
-
} catch (error) {
192
-
console.error(`Failed to migrate blob ${cid}:`, error);
193
-
failedBlobs.push({
194
-
cid,
195
-
error: error instanceof Error ? error.message : String(error),
196
-
});
197
-
}
198
-
}
199
-
blobCursor = listedBlobs.data.cursor;
200
-
} catch (error) {
201
-
console.error("Error during blob migration batch:", error);
202
-
// If we hit a critical error during blob listing, break the loop
203
-
if (error instanceof Error &&
204
-
(error.message.includes("Unauthorized") ||
205
-
error.message.includes("Invalid token"))) {
206
-
throw error;
207
-
}
208
-
break;
209
-
}
210
-
} while (blobCursor);
211
-
212
-
// Migrate preferences with retry
213
-
const prefs = await withRetry(
214
-
() => oldAgent.app.bsky.actor.getPreferences(),
215
-
{
216
-
maxRetries: 3,
217
-
onRetry: (attempt, error) => {
218
-
console.log(`Retrying preferences fetch (attempt ${attempt}):`, error.message);
219
-
},
220
-
}
221
-
);
222
-
223
-
await withRetry(
224
-
() => newAgent.app.bsky.actor.putPreferences(prefs.data),
225
-
{
226
-
maxRetries: 3,
227
-
onRetry: (attempt, error) => {
228
-
console.log(`Retrying preferences update (attempt ${attempt}):`, error.message);
229
-
},
230
-
}
231
-
);
232
-
233
-
return new Response(
234
-
JSON.stringify({
235
-
success: true,
236
-
message: failedBlobs.length > 0
237
-
? `Data migration completed with ${failedBlobs.length} failed blobs`
238
-
: "Data migration completed successfully",
239
-
migratedBlobs,
240
-
failedBlobs,
241
-
totalMigrated: migratedBlobs.length,
242
-
totalFailed: failedBlobs.length,
243
-
}),
244
-
{
245
-
status: failedBlobs.length > 0 ? 207 : 200, // Use 207 Multi-Status if some blobs failed
246
-
headers: {
247
-
"Content-Type": "application/json",
248
-
...Object.fromEntries(res.headers), // Include session cookie headers
249
-
},
250
-
},
251
-
);
252
-
} catch (error) {
253
-
console.error("Data migration error:", error);
254
-
return new Response(
255
-
JSON.stringify({
256
-
success: false,
257
-
message: error instanceof Error
258
-
? error.message
259
-
: "Failed to migrate data",
260
-
error: error instanceof Error ? {
261
-
name: error.name,
262
-
message: error.message,
263
-
stack: error.stack,
264
-
} : String(error),
265
-
}),
266
-
{
267
-
status: 400,
268
-
headers: { "Content-Type": "application/json" },
269
-
},
270
-
);
271
-
}
272
-
},
273
-
});
+17
routes/api/migrate/finalize.ts
+17
routes/api/migrate/finalize.ts
···
1
1
import { getSessionAgent } from "../../../lib/sessions.ts";
2
+
import { checkDidsMatch } from "../../../lib/check-dids.ts";
2
3
import { define } from "../../../utils.ts";
4
+
import { assertMigrationAllowed } from "../../../lib/migration-state.ts";
3
5
4
6
export const handler = define.handlers({
5
7
async POST(ctx) {
6
8
const res = new Response();
7
9
try {
10
+
// Check if migrations are currently allowed
11
+
assertMigrationAllowed();
12
+
8
13
const oldAgent = await getSessionAgent(ctx.req);
9
14
const newAgent = await getSessionAgent(ctx.req, res, true);
10
15
···
13
18
return new Response("Migration session not found or invalid", {
14
19
status: 400,
15
20
});
21
+
}
22
+
23
+
// Verify DIDs match between sessions
24
+
const didsMatch = await checkDidsMatch(ctx.req);
25
+
if (!didsMatch) {
26
+
return new Response(
27
+
JSON.stringify({
28
+
success: false,
29
+
message: "Invalid state, original and target DIDs do not match",
30
+
}),
31
+
{ status: 400, headers: { "Content-Type": "application/json" } },
32
+
);
16
33
}
17
34
18
35
// Activate new account and deactivate old account
+29
-4
routes/api/migrate/identity/request.ts
+29
-4
routes/api/migrate/identity/request.ts
···
1
-
import {
2
-
getSessionAgent,
3
-
} from "../../../../lib/sessions.ts";
1
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
2
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
4
3
import { define } from "../../../../utils.ts";
4
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
5
5
6
+
/**
7
+
* Handle identity migration request
8
+
* Sends a PLC operation signature request to the old account's email
9
+
* Should be called after all data is migrated to the new account
10
+
* @param ctx - The context object containing the request and response
11
+
* @returns A response object with the migration result
12
+
*/
6
13
export const handler = define.handlers({
7
14
async POST(ctx) {
8
15
const res = new Response();
9
16
try {
17
+
// Check if migrations are currently allowed
18
+
assertMigrationAllowed();
19
+
10
20
console.log("Starting identity migration request...");
11
21
const oldAgent = await getSessionAgent(ctx.req);
12
22
console.log("Got old agent:", {
···
45
55
);
46
56
}
47
57
58
+
// Verify DIDs match between sessions
59
+
const didsMatch = await checkDidsMatch(ctx.req);
60
+
if (!didsMatch) {
61
+
return new Response(
62
+
JSON.stringify({
63
+
success: false,
64
+
message: "Invalid state, original and target DIDs do not match",
65
+
}),
66
+
{
67
+
status: 400,
68
+
headers: { "Content-Type": "application/json" },
69
+
},
70
+
);
71
+
}
72
+
48
73
// Request the signature
49
74
console.log("Requesting PLC operation signature...");
50
75
try {
···
54
79
console.error("Error requesting PLC operation signature:", {
55
80
name: error instanceof Error ? error.name : "Unknown",
56
81
message: error instanceof Error ? error.message : String(error),
57
-
status: 400
82
+
status: 400,
58
83
});
59
84
throw error;
60
85
}
+27
-3
routes/api/migrate/identity/sign.ts
+27
-3
routes/api/migrate/identity/sign.ts
···
1
-
import {
2
-
getSessionAgent,
3
-
} from "../../../../lib/sessions.ts";
1
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
2
+
import { checkDidsMatch } from "../../../../lib/check-dids.ts";
4
3
import { Secp256k1Keypair } from "npm:@atproto/crypto";
5
4
import * as ui8 from "npm:uint8arrays";
6
5
import { define } from "../../../../utils.ts";
6
+
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
7
7
8
+
/**
9
+
* Handle identity migration sign
10
+
* Should be called after user receives the migration token via email
11
+
* URL params must contain the token
12
+
* @param ctx - The context object containing the request with the token in the URL params
13
+
* @returns A response object with the migration result
14
+
*/
8
15
export const handler = define.handlers({
9
16
async POST(ctx) {
10
17
const res = new Response();
11
18
try {
19
+
// Check if migrations are currently allowed
20
+
assertMigrationAllowed();
12
21
const url = new URL(ctx.req.url);
13
22
const token = url.searchParams.get("token");
14
23
···
45
54
JSON.stringify({
46
55
success: false,
47
56
message: "Migration session not found or invalid",
57
+
}),
58
+
{
59
+
status: 400,
60
+
headers: { "Content-Type": "application/json" },
61
+
},
62
+
);
63
+
}
64
+
65
+
// Verify DIDs match between sessions
66
+
const didsMatch = await checkDidsMatch(ctx.req);
67
+
if (!didsMatch) {
68
+
return new Response(
69
+
JSON.stringify({
70
+
success: false,
71
+
message: "Invalid state, original and target DIDs do not match",
48
72
}),
49
73
{
50
74
status: 400,
+2
-2
routes/api/migrate/next-step.ts
+2
-2
routes/api/migrate/next-step.ts
···
17
17
// Check conditions in sequence to determine the next step
18
18
if (!newStatus.data) {
19
19
nextStep = 1;
20
-
} else if (!(newStatus.data.repoCommit &&
20
+
} else if (!(newStatus.data.repoCommit &&
21
21
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
22
22
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
23
23
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
···
42
42
}
43
43
});
44
44
}
45
-
})
45
+
})
+135
-71
routes/api/migrate/status.ts
+135
-71
routes/api/migrate/status.ts
···
1
+
import { checkDidsMatch } from "../../../lib/check-dids.ts";
1
2
import { getSessionAgent } from "../../../lib/sessions.ts";
2
3
import { define } from "../../../utils.ts";
3
4
4
5
export const handler = define.handlers({
5
-
async GET(ctx) {
6
-
const url = new URL(ctx.req.url);
7
-
const params = new URLSearchParams(url.search);
8
-
const step = params.get("step");
9
-
const oldAgent = await getSessionAgent(ctx.req);
10
-
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
11
-
12
-
if (!oldAgent || !newAgent) return new Response("Unauthorized", { status: 401 });
6
+
async GET(ctx) {
7
+
console.log("Status check: Starting");
8
+
const url = new URL(ctx.req.url);
9
+
const params = new URLSearchParams(url.search);
10
+
const step = params.get("step");
11
+
console.log("Status check: Step", step);
13
12
14
-
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
15
-
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
16
-
if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 });
13
+
console.log("Status check: Getting agents");
14
+
const oldAgent = await getSessionAgent(ctx.req);
15
+
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
17
16
18
-
const readyToContinue = () => {
19
-
if (step) {
20
-
switch (step) {
21
-
case "1": {
22
-
if (newStatus.data) {
23
-
return { ready: true };
24
-
}
25
-
return { ready: false, reason: "New account status not available" };
26
-
}
27
-
case "2": {
28
-
if (newStatus.data.repoCommit &&
29
-
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
30
-
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
31
-
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
32
-
newStatus.data.importedBlobs === oldStatus.data.importedBlobs) {
33
-
return { ready: true };
34
-
}
35
-
const reasons = [];
36
-
if (!newStatus.data.repoCommit) reasons.push("Repository not imported.");
37
-
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords)
38
-
reasons.push("Not all records imported.");
39
-
if (newStatus.data.privateStateValues < oldStatus.data.privateStateValues)
40
-
reasons.push("Not all private state values imported.");
41
-
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs)
42
-
reasons.push("Expected blobs not fully imported.");
43
-
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs)
44
-
reasons.push("Not all blobs imported.");
45
-
return { ready: false, reason: reasons.join(", ") };
46
-
}
47
-
case "3": {
48
-
if (newStatus.data.validDid) {
49
-
return { ready: true };
50
-
}
51
-
return { ready: false, reason: "DID not valid" };
52
-
}
53
-
case "4": {
54
-
if (newStatus.data.activated === true && oldStatus.data.activated === false) {
55
-
return { ready: true };
56
-
}
57
-
return { ready: false, reason: "Account not activated" };
58
-
}
59
-
}
60
-
} else {
61
-
return { ready: true };
17
+
if (!oldAgent || !newAgent) {
18
+
console.log("Status check: Unauthorized - missing agents", {
19
+
hasOldAgent: !!oldAgent,
20
+
hasNewAgent: !!newAgent,
21
+
});
22
+
return new Response("Unauthorized", { status: 401 });
23
+
}
24
+
25
+
const didsMatch = await checkDidsMatch(ctx.req);
26
+
27
+
console.log("Status check: Fetching account statuses");
28
+
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
29
+
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
30
+
31
+
if (!oldStatus.data || !newStatus.data) {
32
+
console.error("Status check: Failed to verify status", {
33
+
hasOldStatus: !!oldStatus.data,
34
+
hasNewStatus: !!newStatus.data,
35
+
});
36
+
return new Response("Could not verify status", { status: 500 });
37
+
}
38
+
39
+
console.log("Status check: Account statuses", {
40
+
old: oldStatus.data,
41
+
new: newStatus.data,
42
+
});
43
+
44
+
const readyToContinue = () => {
45
+
if (!didsMatch) {
46
+
return {
47
+
ready: false,
48
+
reason: "Invalid state, original and target DIDs do not match",
49
+
};
50
+
}
51
+
if (step) {
52
+
console.log("Status check: Evaluating step", step);
53
+
switch (step) {
54
+
case "1": {
55
+
if (newStatus.data) {
56
+
console.log("Status check: Step 1 ready");
57
+
return { ready: true };
62
58
}
59
+
console.log(
60
+
"Status check: Step 1 not ready - new account status not available",
61
+
);
62
+
return { ready: false, reason: "New account status not available" };
63
+
}
64
+
case "2": {
65
+
const isReady = newStatus.data.repoCommit &&
66
+
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
67
+
newStatus.data.privateStateValues ===
68
+
oldStatus.data.privateStateValues &&
69
+
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
70
+
newStatus.data.importedBlobs === oldStatus.data.importedBlobs;
71
+
72
+
if (isReady) {
73
+
console.log("Status check: Step 2 ready");
74
+
return { ready: true };
75
+
}
76
+
77
+
const reasons = [];
78
+
if (!newStatus.data.repoCommit) {
79
+
reasons.push("Repository not imported.");
80
+
}
81
+
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords) {
82
+
reasons.push("Not all records imported.");
83
+
}
84
+
if (
85
+
newStatus.data.privateStateValues <
86
+
oldStatus.data.privateStateValues
87
+
) {
88
+
reasons.push("Not all private state values imported.");
89
+
}
90
+
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs) {
91
+
reasons.push("Expected blobs not fully imported.");
92
+
}
93
+
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs) {
94
+
reasons.push("Not all blobs imported.");
95
+
}
96
+
97
+
console.log("Status check: Step 2 not ready", { reasons });
98
+
return { ready: false, reason: reasons.join(", ") };
99
+
}
100
+
case "3": {
101
+
if (newStatus.data.validDid) {
102
+
console.log("Status check: Step 3 ready");
103
+
return { ready: true };
104
+
}
105
+
console.log("Status check: Step 3 not ready - DID not valid");
106
+
return { ready: false, reason: "DID not valid" };
107
+
}
108
+
case "4": {
109
+
if (
110
+
newStatus.data.activated === true &&
111
+
oldStatus.data.activated === false
112
+
) {
113
+
console.log("Status check: Step 4 ready");
114
+
return { ready: true };
115
+
}
116
+
console.log(
117
+
"Status check: Step 4 not ready - Account not activated",
118
+
);
119
+
return { ready: false, reason: "Account not activated" };
120
+
}
63
121
}
122
+
} else {
123
+
console.log("Status check: No step specified, returning ready");
124
+
return { ready: true };
125
+
}
126
+
};
64
127
65
-
const status = {
66
-
activated: newStatus.data.activated,
67
-
validDid: newStatus.data.validDid,
68
-
repoCommit: newStatus.data.repoCommit,
69
-
repoRev: newStatus.data.repoRev,
70
-
repoBlocks: newStatus.data.repoBlocks,
71
-
expectedRecords: oldStatus.data.indexedRecords,
72
-
indexedRecords: newStatus.data.indexedRecords,
73
-
privateStateValues: newStatus.data.privateStateValues,
74
-
expectedBlobs: newStatus.data.expectedBlobs,
75
-
importedBlobs: newStatus.data.importedBlobs,
76
-
...readyToContinue()
77
-
}
128
+
const status = {
129
+
activated: newStatus.data.activated,
130
+
validDid: newStatus.data.validDid,
131
+
repoCommit: newStatus.data.repoCommit,
132
+
repoRev: newStatus.data.repoRev,
133
+
repoBlocks: newStatus.data.repoBlocks,
134
+
expectedRecords: oldStatus.data.indexedRecords,
135
+
indexedRecords: newStatus.data.indexedRecords,
136
+
privateStateValues: newStatus.data.privateStateValues,
137
+
expectedBlobs: newStatus.data.expectedBlobs,
138
+
importedBlobs: newStatus.data.importedBlobs,
139
+
...readyToContinue(),
140
+
};
78
141
79
-
return Response.json(status);
80
-
}
81
-
})
142
+
console.log("Status check: Complete", status);
143
+
return Response.json(status);
144
+
},
145
+
});
+44
routes/api/migration-state.ts
+44
routes/api/migration-state.ts
···
1
+
import { getMigrationState } from "../../lib/migration-state.ts";
2
+
import { define } from "../../utils.ts";
3
+
4
+
/**
5
+
* API endpoint to check the current migration state.
6
+
* Returns the migration state information including whether migrations are allowed.
7
+
*/
8
+
export const handler = define.handlers({
9
+
GET(_ctx) {
10
+
try {
11
+
const stateInfo = getMigrationState();
12
+
13
+
return new Response(
14
+
JSON.stringify({
15
+
state: stateInfo.state,
16
+
message: stateInfo.message,
17
+
allowMigration: stateInfo.allowMigration,
18
+
}),
19
+
{
20
+
status: 200,
21
+
headers: {
22
+
"Content-Type": "application/json",
23
+
},
24
+
}
25
+
);
26
+
} catch (error) {
27
+
console.error("Error checking migration state:", error);
28
+
29
+
return new Response(
30
+
JSON.stringify({
31
+
state: "issue",
32
+
message: "Unable to determine migration state. Please try again later.",
33
+
allowMigration: false,
34
+
}),
35
+
{
36
+
status: 500,
37
+
headers: {
38
+
"Content-Type": "application/json",
39
+
},
40
+
}
41
+
);
42
+
}
43
+
},
44
+
});
+42
routes/api/plc/keys.ts
+42
routes/api/plc/keys.ts
···
1
+
import { Secp256k1Keypair } from "@atproto/crypto";
2
+
import { getSessionAgent } from "../../../lib/sessions.ts";
3
+
import { define } from "../../../utils.ts";
4
+
import * as ui8 from "npm:uint8arrays";
5
+
6
+
/**
7
+
* Generate and return PLC keys for the authenticated user
8
+
*/
9
+
export const handler = define.handlers({
10
+
async GET(ctx) {
11
+
const agent = await getSessionAgent(ctx.req);
12
+
if (!agent) {
13
+
return new Response("Unauthorized", { status: 401 });
14
+
}
15
+
16
+
// Create a new keypair
17
+
const keypair = await Secp256k1Keypair.create({ exportable: true });
18
+
19
+
// Export private key bytes
20
+
const privateKeyBytes = await keypair.export();
21
+
const privateKeyHex = ui8.toString(privateKeyBytes, "hex");
22
+
23
+
// Get public key as DID
24
+
const publicKeyDid = keypair.did();
25
+
26
+
// Convert private key to multikey format (base58btc)
27
+
const privateKeyMultikey = ui8.toString(privateKeyBytes, "base58btc");
28
+
29
+
// Return the key information
30
+
return new Response(
31
+
JSON.stringify({
32
+
keyType: "secp256k1",
33
+
publicKeyDid: publicKeyDid,
34
+
privateKeyHex: privateKeyHex,
35
+
privateKeyMultikey: privateKeyMultikey,
36
+
}),
37
+
{
38
+
headers: { "Content-Type": "application/json" },
39
+
}
40
+
);
41
+
},
42
+
});
+64
routes/api/plc/token.ts
+64
routes/api/plc/token.ts
···
1
+
import { getSessionAgent } from "../../../lib/sessions.ts";
2
+
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
3
+
import { Agent } from "@atproto/api";
4
+
import { define } from "../../../utils.ts";
5
+
6
+
/**
7
+
* Handle account creation
8
+
* First step of the migration process
9
+
* Body must contain:
10
+
* - service: The service URL of the new account
11
+
* - handle: The handle of the new account
12
+
* - password: The password of the new account
13
+
* - email: The email of the new account
14
+
* - invite: The invite code of the new account (optional depending on the PDS)
15
+
* @param ctx - The context object containing the request and response
16
+
* @returns A response object with the creation result
17
+
*/
18
+
export const handler = define.handlers({
19
+
async GET(ctx) {
20
+
const res = new Response();
21
+
try {
22
+
const agent = await getSessionAgent(ctx.req, res);
23
+
24
+
if (!agent) return new Response("Unauthorized", { status: 401 });
25
+
26
+
// console.log("getting did");
27
+
// const session = await agent.com.atproto.server.getSession();
28
+
// const accountDid = session.data.did;
29
+
// console.log("got did");
30
+
31
+
await agent.com.atproto.identity.requestPlcOperationSignature();
32
+
33
+
return new Response(
34
+
JSON.stringify({
35
+
success: true,
36
+
message:
37
+
"We've requested a token to update your identity, it should be sent to your account's email address.",
38
+
}),
39
+
{
40
+
status: 200,
41
+
headers: {
42
+
"Content-Type": "application/json",
43
+
...Object.fromEntries(res.headers), // Include session cookie headers
44
+
},
45
+
}
46
+
);
47
+
} catch (error) {
48
+
console.error("PLC signature request error:", error);
49
+
return new Response(
50
+
JSON.stringify({
51
+
success: false,
52
+
message:
53
+
error instanceof Error
54
+
? error.message
55
+
: "Failed to get PLC operation signature (sending confirmation email)",
56
+
}),
57
+
{
58
+
status: 400,
59
+
headers: { "Content-Type": "application/json" },
60
+
}
61
+
);
62
+
}
63
+
},
64
+
});
+92
routes/api/plc/update/complete.ts
+92
routes/api/plc/update/complete.ts
···
1
+
import { Agent } from "@atproto/api";
2
+
import { getSessionAgent } from "../../../../lib/sessions.ts";
3
+
import { define } from "../../../../utils.ts";
4
+
5
+
/**
6
+
* Complete PLC update using email token
7
+
*/
8
+
export const handler = define.handlers({
9
+
async POST(ctx) {
10
+
const res = new Response();
11
+
try {
12
+
const url = new URL(ctx.req.url);
13
+
const token = url.searchParams.get("token");
14
+
15
+
if (!token) {
16
+
return new Response(
17
+
JSON.stringify({
18
+
success: false,
19
+
message: "Missing token parameter",
20
+
}),
21
+
{
22
+
status: 400,
23
+
headers: { "Content-Type": "application/json" },
24
+
}
25
+
);
26
+
}
27
+
28
+
const agent = await getSessionAgent(ctx.req, res, true);
29
+
if (!agent) {
30
+
return new Response(
31
+
JSON.stringify({
32
+
success: false,
33
+
message: "Unauthorized",
34
+
}),
35
+
{
36
+
status: 401,
37
+
headers: { "Content-Type": "application/json" },
38
+
}
39
+
);
40
+
}
41
+
42
+
const did = agent.did;
43
+
if (!did) {
44
+
return new Response(
45
+
JSON.stringify({
46
+
success: false,
47
+
message: "No DID found in session",
48
+
}),
49
+
{
50
+
status: 400,
51
+
headers: { "Content-Type": "application/json" },
52
+
}
53
+
);
54
+
}
55
+
56
+
// Submit the PLC operation with the token
57
+
await agent!.com.atproto.identity.submitPlcOperation({
58
+
operation: { token: token },
59
+
});
60
+
61
+
return new Response(
62
+
JSON.stringify({
63
+
success: true,
64
+
message: "PLC update completed successfully",
65
+
did,
66
+
}),
67
+
{
68
+
status: 200,
69
+
headers: {
70
+
"Content-Type": "application/json",
71
+
...Object.fromEntries(res.headers), // Include session cookie headers
72
+
},
73
+
}
74
+
);
75
+
} catch (error) {
76
+
console.error("PLC update completion error:", error);
77
+
const message =
78
+
error instanceof Error ? error.message : "Unknown error occurred";
79
+
80
+
return new Response(
81
+
JSON.stringify({
82
+
success: false,
83
+
message: `Failed to complete PLC update: ${message}`,
84
+
}),
85
+
{
86
+
status: 500,
87
+
headers: { "Content-Type": "application/json" },
88
+
}
89
+
);
90
+
}
91
+
},
92
+
});
+155
routes/api/plc/update.ts
+155
routes/api/plc/update.ts
···
1
+
import { getSessionAgent } from "../../../lib/sessions.ts";
2
+
import { define } from "../../../utils.ts";
3
+
import * as plc from "@did-plc/lib";
4
+
5
+
/**
6
+
* Handle PLC update operation
7
+
* Body must contain:
8
+
* - key: The new rotation key to add
9
+
* - token: The email token received from requestPlcOperationSignature
10
+
* @param ctx - The context object containing the request and response
11
+
* @returns A response object with the update result
12
+
*/
13
+
export const handler = define.handlers({
14
+
async POST(ctx) {
15
+
const res = new Response();
16
+
try {
17
+
console.log("=== PLC Update Debug ===");
18
+
const body = await ctx.req.json();
19
+
const { key: newKey, token } = body;
20
+
console.log("Request body:", { newKey, hasToken: !!token });
21
+
22
+
if (!newKey) {
23
+
console.log("Missing key in request");
24
+
return new Response("Missing param key in request body", {
25
+
status: 400,
26
+
});
27
+
}
28
+
29
+
if (!token) {
30
+
console.log("Missing token in request");
31
+
return new Response("Missing param token in request body", {
32
+
status: 400,
33
+
});
34
+
}
35
+
36
+
const agent = await getSessionAgent(ctx.req, res);
37
+
if (!agent) {
38
+
console.log("No agent found");
39
+
return new Response("Unauthorized", { status: 401 });
40
+
}
41
+
42
+
const session = await agent.com.atproto.server.getSession();
43
+
const did = session.data.did;
44
+
if (!did) {
45
+
console.log("No DID found in session");
46
+
return new Response(
47
+
JSON.stringify({
48
+
success: false,
49
+
message: "No DID found in your session",
50
+
}),
51
+
{
52
+
status: 400,
53
+
headers: { "Content-Type": "application/json" },
54
+
}
55
+
);
56
+
}
57
+
console.log("Using agent DID:", did);
58
+
59
+
// Get recommended credentials first
60
+
console.log("Getting did:plc document...");
61
+
const plcClient = new plc.Client("https://plc.directory");
62
+
const didDoc = await plcClient.getDocumentData(did);
63
+
if (!didDoc) {
64
+
console.log("No DID document found for agent DID");
65
+
return new Response(
66
+
JSON.stringify({
67
+
success: false,
68
+
message: "No DID document found for your account",
69
+
}),
70
+
{
71
+
status: 400,
72
+
headers: { "Content-Type": "application/json" },
73
+
}
74
+
);
75
+
}
76
+
console.log("Got DID document:", didDoc);
77
+
78
+
const rotationKeys = didDoc.rotationKeys ?? [];
79
+
if (!rotationKeys.length) {
80
+
console.log("No existing rotation keys found");
81
+
throw new Error("No rotation keys provided in recommended credentials");
82
+
}
83
+
84
+
// Check if the key is already in rotation keys
85
+
if (rotationKeys.includes(newKey)) {
86
+
console.log("Key already exists in rotation keys");
87
+
return new Response(
88
+
JSON.stringify({
89
+
success: false,
90
+
message: "This key is already in your rotation keys",
91
+
}),
92
+
{
93
+
status: 400,
94
+
headers: { "Content-Type": "application/json" },
95
+
}
96
+
);
97
+
}
98
+
99
+
// Perform the actual PLC update with the provided token
100
+
console.log("Signing PLC operation...");
101
+
const plcOp = await agent.com.atproto.identity.signPlcOperation({
102
+
token,
103
+
rotationKeys: [newKey, ...rotationKeys],
104
+
});
105
+
console.log("PLC operation signed successfully:", plcOp.data);
106
+
107
+
console.log("Submitting PLC operation...");
108
+
const plcSubmit = await agent.com.atproto.identity.submitPlcOperation({
109
+
operation: plcOp.data.operation,
110
+
});
111
+
console.log("PLC operation submitted successfully:", plcSubmit);
112
+
113
+
return new Response(
114
+
JSON.stringify({
115
+
success: true,
116
+
message: "PLC update completed successfully",
117
+
did: plcOp.data,
118
+
newKey,
119
+
rotationKeys: [newKey, ...rotationKeys],
120
+
}),
121
+
{
122
+
status: 200,
123
+
headers: {
124
+
"Content-Type": "application/json",
125
+
...Object.fromEntries(res.headers), // Include session cookie headers
126
+
},
127
+
}
128
+
);
129
+
} catch (error) {
130
+
console.error("PLC update error:", error);
131
+
const errorMessage =
132
+
error instanceof Error ? error.message : "Failed to update your PLC";
133
+
console.log("Sending error response:", errorMessage);
134
+
135
+
return new Response(
136
+
JSON.stringify({
137
+
success: false,
138
+
message: errorMessage,
139
+
error:
140
+
error instanceof Error
141
+
? {
142
+
name: error.name,
143
+
message: error.message,
144
+
stack: error.stack,
145
+
}
146
+
: String(error),
147
+
}),
148
+
{
149
+
status: 400,
150
+
headers: { "Content-Type": "application/json" },
151
+
}
152
+
);
153
+
}
154
+
},
155
+
});
+131
routes/api/plc/verify.ts
+131
routes/api/plc/verify.ts
···
1
+
import { getSessionAgent } from "../../../lib/sessions.ts";
2
+
import { define } from "../../../utils.ts";
3
+
import * as plc from "@did-plc/lib";
4
+
5
+
/**
6
+
* Verify if a rotation key exists in the PLC document
7
+
* Body must contain:
8
+
* - key: The rotation key to verify
9
+
* @param ctx - The context object containing the request and response
10
+
* @returns A response object with the verification result
11
+
*/
12
+
export const handler = define.handlers({
13
+
async POST(ctx) {
14
+
const res = new Response();
15
+
try {
16
+
const body = await ctx.req.json();
17
+
const { key: newKey } = body;
18
+
console.log("Request body:", { newKey });
19
+
20
+
if (!newKey) {
21
+
console.log("Missing key in request");
22
+
return new Response("Missing param key in request body", {
23
+
status: 400,
24
+
});
25
+
}
26
+
27
+
const agent = await getSessionAgent(ctx.req, res);
28
+
if (!agent) {
29
+
console.log("No agent found");
30
+
return new Response("Unauthorized", { status: 401 });
31
+
}
32
+
33
+
const session = await agent.com.atproto.server.getSession();
34
+
const did = session.data.did;
35
+
if (!did) {
36
+
console.log("No DID found in session");
37
+
return new Response(
38
+
JSON.stringify({
39
+
success: false,
40
+
message: "No DID found in your session",
41
+
}),
42
+
{
43
+
status: 400,
44
+
headers: { "Content-Type": "application/json" },
45
+
}
46
+
);
47
+
}
48
+
console.log("Using agent DID:", did);
49
+
50
+
// Fetch the PLC document to check rotation keys
51
+
console.log("Getting did:plc document...");
52
+
const plcClient = new plc.Client("https://plc.directory");
53
+
const didDoc = await plcClient.getDocumentData(did);
54
+
if (!didDoc) {
55
+
console.log("No DID document found for agent DID");
56
+
return new Response(
57
+
JSON.stringify({
58
+
success: false,
59
+
message: "No DID document found for your account",
60
+
}),
61
+
{
62
+
status: 400,
63
+
headers: { "Content-Type": "application/json" },
64
+
}
65
+
);
66
+
}
67
+
console.log("Got DID document:", didDoc);
68
+
69
+
const rotationKeys = didDoc.rotationKeys ?? [];
70
+
if (!rotationKeys.length) {
71
+
console.log("No existing rotation keys found");
72
+
throw new Error("No rotation keys found in did:plc document");
73
+
}
74
+
75
+
// Check if the key exists in rotation keys
76
+
if (rotationKeys.includes(newKey)) {
77
+
return new Response(
78
+
JSON.stringify({
79
+
success: true,
80
+
message: "Rotation key exists in PLC document",
81
+
}),
82
+
{
83
+
status: 200,
84
+
headers: {
85
+
"Content-Type": "application/json",
86
+
...Object.fromEntries(res.headers), // Include session cookie headers
87
+
},
88
+
}
89
+
);
90
+
}
91
+
92
+
// If we get here, the key was not found
93
+
return new Response(
94
+
JSON.stringify({
95
+
success: false,
96
+
message: "Rotation key not found in PLC document",
97
+
}),
98
+
{
99
+
status: 404,
100
+
headers: { "Content-Type": "application/json" },
101
+
}
102
+
);
103
+
} catch (error) {
104
+
console.error("PLC verification error:", error);
105
+
const errorMessage =
106
+
error instanceof Error
107
+
? error.message
108
+
: "Failed to verify rotation key";
109
+
console.log("Sending error response:", errorMessage);
110
+
111
+
return new Response(
112
+
JSON.stringify({
113
+
success: false,
114
+
message: errorMessage,
115
+
error:
116
+
error instanceof Error
117
+
? {
118
+
name: error.name,
119
+
message: error.message,
120
+
stack: error.stack,
121
+
}
122
+
: String(error),
123
+
}),
124
+
{
125
+
status: 400,
126
+
headers: { "Content-Type": "application/json" },
127
+
}
128
+
);
129
+
}
130
+
},
131
+
});
+30
routes/api/resolve-pds.ts
+30
routes/api/resolve-pds.ts
···
1
+
import { resolver } from "../../lib/id-resolver.ts";
2
+
import { define } from "../../utils.ts";
3
+
4
+
export const handler = define.handlers({
5
+
async GET(ctx) {
6
+
const url = new URL(ctx.req.url);
7
+
const did = url.searchParams.get("did");
8
+
9
+
if (!did) {
10
+
return new Response(JSON.stringify({ error: "DID parameter is required" }), {
11
+
status: 400,
12
+
headers: { "Content-Type": "application/json" }
13
+
});
14
+
}
15
+
16
+
try {
17
+
const pds = await resolver.resolveDidToPdsUrl(did);
18
+
return new Response(JSON.stringify({ pds }), {
19
+
status: 200,
20
+
headers: { "Content-Type": "application/json" }
21
+
});
22
+
} catch (error) {
23
+
console.error("Failed to resolve PDS:", error);
24
+
return new Response(JSON.stringify({ error: "Failed to resolve PDS" }), {
25
+
status: 500,
26
+
headers: { "Content-Type": "application/json" }
27
+
});
28
+
}
29
+
}
30
+
});
+8
-17
routes/index.tsx
+8
-17
routes/index.tsx
···
1
1
import Ticket from "../islands/Ticket.tsx";
2
2
import AirportSign from "../components/AirportSign.tsx";
3
3
import SocialLinks from "../islands/SocialLinks.tsx";
4
-
import { Button } from "../components/Button.tsx";
4
+
import LoginButton from "../islands/LoginButton.tsx";
5
5
6
6
export default function Home() {
7
7
return (
···
14
14
<p class="font-mono text-lg sm:text-xl font-bold mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300">
15
15
Your terminal for seamless AT Protocol PDS migration and backup.
16
16
</p>
17
-
<p class="font-mono mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300">
18
-
Airport is in <strong>alpha</strong> currently, and we don't recommend it for main accounts. <br/> Please use its migration tools at your own risk.
19
-
</p>
20
17
21
18
<Ticket />
22
19
23
-
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
24
-
<Button
25
-
href="/login"
26
-
color="blue"
27
-
label="MOBILE NOT SUPPORTED"
28
-
className="opacity-50 cursor-not-allowed sm:opacity-100 sm:cursor-pointer"
29
-
onClick={(e: MouseEvent) => {
30
-
if (globalThis.innerWidth < 640) {
31
-
e.preventDefault();
32
-
}
33
-
}}
34
-
/>
35
-
</div>
20
+
<LoginButton />
36
21
<p class="font-mono text-lg sm:text-xl mb-4 mt-4 sm:mb-6 text-center text-gray-600 dark:text-gray-300">
37
22
Airport is made with love by <a class="text-blue-500 hover:underline" href="https://bsky.app/profile/knotbin.com">Roscoe</a> for <a class="text-blue-500 hover:underline" href="https://sprk.so">Spark</a>, a new short-video platform for AT Protocol.
38
23
</p>
24
+
<div class="text-center mb-4">
25
+
<a href="/about" class="inline-flex items-center text-blue-500 hover:text-blue-600 transition-colors">
26
+
<img src="/icons/info_bold.svg" alt="Info" class="w-5 h-5 mr-2" />
27
+
<span class="font-mono">Learn more about AT Protocol</span>
28
+
</a>
29
+
</div>
39
30
<SocialLinks />
40
31
</div>
41
32
</div>
+2
-2
routes/migrate/progress.tsx
+2
-2
routes/migrate/progress.tsx
···
10
10
11
11
if (!service || !handle || !email || !password) {
12
12
return (
13
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
13
+
<div class="bg-gray-50 dark:bg-gray-900 p-4">
14
14
<div class="max-w-2xl mx-auto">
15
15
<div class="bg-red-50 dark:bg-red-900 p-4 rounded-lg">
16
16
<p class="text-red-800 dark:text-red-200">
···
24
24
}
25
25
26
26
return (
27
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
27
+
<div class="bg-gray-50 dark:bg-gray-900 p-4">
28
28
<div class="max-w-2xl mx-auto">
29
29
<h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8">
30
30
Migration Progress
+19
routes/ticket-booth/index.tsx
+19
routes/ticket-booth/index.tsx
···
1
+
import { PageProps } from "fresh";
2
+
import MigrationSetup from "../../islands/MigrationSetup.tsx";
3
+
import DidPlcProgress from "../../islands/DidPlcProgress.tsx";
4
+
5
+
export default function TicketBooth(props: PageProps) {
6
+
const service = props.url.searchParams.get("service");
7
+
const handle = props.url.searchParams.get("handle");
8
+
9
+
return (
10
+
<div class=" bg-gray-50 dark:bg-gray-900 p-4">
11
+
<div class="max-w-2xl mx-auto">
12
+
<h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8">
13
+
Ticket Booth Self-Service Kiosk
14
+
</h1>
15
+
<DidPlcProgress />
16
+
</div>
17
+
</div>
18
+
);
19
+
}
+4
static/icons/account.svg
+4
static/icons/account.svg
+6
static/icons/info_bold.svg
+6
static/icons/info_bold.svg
···
1
+
<?xml version="1.0" encoding="UTF-8"?>
2
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
3
+
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
4
+
<path d="M12 16V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
5
+
<path d="M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
6
+
</svg>