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