+2
-1
dev.ts
+2
-1
dev.ts
···
1
1
import { Builder } from "fresh/dev";
2
2
import { tailwind } from "@fresh/plugin-tailwind";
3
+
import { State } from "./utils.ts";
3
4
4
-
const builder = new Builder({ target: "safari12" });
5
+
const builder = new Builder<State>({ target: "safari12" });
5
6
tailwind(builder);
6
7
7
8
if (Deno.args.includes("build")) {
+93
-54
islands/DidPlcProgress.tsx
+93
-54
islands/DidPlcProgress.tsx
···
162
162
const [emailToken, setEmailToken] = useState<string>("");
163
163
const [hasDownloadedKey, setHasDownloadedKey] = useState(false);
164
164
const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null);
165
+
const [hasContinuedPastDownload, setHasContinuedPastDownload] = useState(
166
+
false,
167
+
);
165
168
166
169
const updateStepStatus = (
167
170
index: number,
···
444
447
445
448
try {
446
449
const jsonString = JSON.stringify(keyJson, null, 2);
447
-
const blob = new Blob([jsonString], {
448
-
type: "application/json",
449
-
});
450
-
const url = URL.createObjectURL(blob);
451
-
const a = document.createElement("a");
452
-
a.href = url;
453
-
a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`;
454
-
a.style.display = "none";
455
-
document.body.appendChild(a);
456
-
a.click();
457
-
document.body.removeChild(a);
458
-
URL.revokeObjectURL(url);
450
+
const filename = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`;
451
+
452
+
// Create data URL
453
+
const dataStr = "data:text/json;charset=utf-8," +
454
+
encodeURIComponent(jsonString);
455
+
456
+
// Create download link
457
+
const downloadAnchorNode = document.createElement("a");
458
+
downloadAnchorNode.setAttribute("href", dataStr);
459
+
downloadAnchorNode.setAttribute("download", filename);
460
+
461
+
// For Chrome/Firefox compatibility
462
+
downloadAnchorNode.style.display = "none";
463
+
document.body.appendChild(downloadAnchorNode);
464
+
465
+
// Trigger download
466
+
downloadAnchorNode.click();
467
+
468
+
// Cleanup
469
+
document.body.removeChild(downloadAnchorNode);
459
470
460
-
console.log("Download completed, proceeding to next step...");
471
+
console.log("Download completed, showing continue button...");
461
472
setHasDownloadedKey(true);
462
473
setDownloadedKeyId(keyJson.publicKeyDid);
463
-
464
-
// Automatically proceed to the next step after successful download
465
-
setTimeout(() => {
466
-
console.log("Auto-proceeding with key:", keyJson.publicKeyDid);
467
-
handleStartPlcUpdate(keyJson.publicKeyDid);
468
-
}, 1000);
474
+
// Keep step 0 in completed state but don't auto-proceed
469
475
} catch (error) {
470
476
console.error("Download failed:", error);
471
477
}
···
845
851
{/* Key Download Warning */}
846
852
{index === 0 &&
847
853
step.status === "completed" &&
848
-
!hasDownloadedKey && (
854
+
!hasContinuedPastDownload && (
849
855
<div class="mt-4 space-y-4">
850
856
<div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
851
857
<div class="flex items-start">
···
893
899
</div>
894
900
895
901
<div class="flex items-center justify-between">
896
-
<button
897
-
type="button"
898
-
onClick={handleDownload}
899
-
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"
900
-
>
901
-
<svg
902
-
class="w-5 h-5"
903
-
fill="none"
904
-
stroke="currentColor"
905
-
viewBox="0 0 24 24"
902
+
<div class="flex items-center space-x-3">
903
+
<button
904
+
type="button"
905
+
onClick={handleDownload}
906
+
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"
906
907
>
907
-
<path
908
-
stroke-linecap="round"
909
-
stroke-linejoin="round"
910
-
stroke-width="2"
911
-
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
912
-
/>
913
-
</svg>
914
-
<span>Download Key</span>
915
-
</button>
908
+
<svg
909
+
class="w-5 h-5"
910
+
fill="none"
911
+
stroke="currentColor"
912
+
viewBox="0 0 24 24"
913
+
>
914
+
<path
915
+
stroke-linecap="round"
916
+
stroke-linejoin="round"
917
+
stroke-width="2"
918
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
919
+
/>
920
+
</svg>
921
+
<span>Download Key</span>
922
+
</button>
916
923
917
-
<div class="flex items-center text-sm text-red-600 dark:text-red-400">
918
-
<svg
919
-
class="w-4 h-4 mr-1"
920
-
fill="none"
921
-
stroke="currentColor"
922
-
viewBox="0 0 24 24"
923
-
>
924
-
<path
925
-
stroke-linecap="round"
926
-
stroke-linejoin="round"
927
-
stroke-width="2"
928
-
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"
929
-
/>
930
-
</svg>
931
-
Download required to proceed
924
+
{hasDownloadedKey && (
925
+
<button
926
+
type="button"
927
+
onClick={() => {
928
+
console.log(
929
+
"Continue clicked, proceeding to PLC update",
930
+
);
931
+
setHasContinuedPastDownload(true);
932
+
handleStartPlcUpdate(keyJson.publicKeyDid);
933
+
}}
934
+
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"
935
+
>
936
+
<svg
937
+
class="w-5 h-5"
938
+
fill="none"
939
+
stroke="currentColor"
940
+
viewBox="0 0 24 24"
941
+
>
942
+
<path
943
+
stroke-linecap="round"
944
+
stroke-linejoin="round"
945
+
stroke-width="2"
946
+
d="M9 5l7 7-7 7"
947
+
/>
948
+
</svg>
949
+
<span>Continue</span>
950
+
</button>
951
+
)}
932
952
</div>
953
+
954
+
{!hasDownloadedKey && (
955
+
<div class="flex items-center text-sm text-red-600 dark:text-red-400">
956
+
<svg
957
+
class="w-4 h-4 mr-1"
958
+
fill="none"
959
+
stroke="currentColor"
960
+
viewBox="0 0 24 24"
961
+
>
962
+
<path
963
+
stroke-linecap="round"
964
+
stroke-linejoin="round"
965
+
stroke-width="2"
966
+
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"
967
+
/>
968
+
</svg>
969
+
Download required to proceed
970
+
</div>
971
+
)}
933
972
</div>
934
973
</div>
935
974
)}
+21
-21
islands/MigrationProgress.tsx
+21
-21
islands/MigrationProgress.tsx
···
37
37
}`,
38
38
);
39
39
setSteps((prevSteps) =>
40
-
prevSteps.map((step, i) =>
41
-
i === index
42
-
? { ...step, status, error, isVerificationError }
43
-
: i > index
44
-
? {
45
-
...step,
46
-
status: "pending",
47
-
error: undefined,
48
-
isVerificationError: undefined,
40
+
prevSteps.map((step, i) => {
41
+
if (i === index) {
42
+
// Update the current step
43
+
return { ...step, status, error, isVerificationError };
44
+
} else if (i > index) {
45
+
// Reset future steps to pending only if current step is erroring
46
+
if (status === "error") {
47
+
return {
48
+
...step,
49
+
status: "pending",
50
+
error: undefined,
51
+
isVerificationError: undefined,
52
+
};
49
53
}
50
-
: step
51
-
)
54
+
// Otherwise keep future steps as they are
55
+
return step;
56
+
} else {
57
+
// Keep previous steps as they are (preserve completed status)
58
+
return step;
59
+
}
60
+
})
52
61
);
53
62
};
54
63
···
152
161
return;
153
162
}
154
163
155
-
try {
156
-
await client.startMigration(props);
157
-
} catch (error) {
158
-
console.error("Unhandled migration error:", error);
159
-
updateStepStatus(
160
-
0,
161
-
"error",
162
-
error as string ?? "Unknown error occurred",
163
-
);
164
-
}
164
+
await client.startMigration(props);
165
165
})();
166
166
}, []);
167
167
+2
-6
lib/client.ts
+2
-6
lib/client.ts
···
205
205
await this.nextStepHook(2);
206
206
}
207
207
208
-
// Step 4: Finalize Migration
209
-
await this.finalizeMigration();
210
-
if (this.nextStepHook) {
211
-
await this.nextStepHook(3);
212
-
}
213
-
208
+
// Stop here - finalization will be called from handleIdentityMigration
209
+
// after user enters the token
214
210
return;
215
211
} catch (error) {
216
212
console.error("Migration error in try/catch:", error);
+22
-11
lib/oauth/client.ts
+22
-11
lib/oauth/client.ts
···
1
1
import { AtprotoOAuthClient } from "@bigmoves/atproto-oauth-client";
2
2
import { SessionStore, StateStore } from "../storage.ts";
3
3
4
+
const isDev = Deno.env.get("NODE_ENV") !== "production";
5
+
export const scope = [
6
+
"atproto",
7
+
"account:email",
8
+
"account:status?action=manage",
9
+
"identity:*",
10
+
"rpc:*?aud=did:web:api.bsky.app#bsky_appview",
11
+
"rpc:com.atproto.server.createAccount?aud=*",
12
+
].join(" ");
13
+
const publicUrl = Deno.env.get("PUBLIC_URL");
14
+
const url = publicUrl || `http://127.0.0.1:8000`;
15
+
export const clientId = publicUrl
16
+
? `${url}/oauth-client-metadata.json`
17
+
: `http://localhost?redirect_uri=${
18
+
encodeURIComponent(`${url}/api/oauth/callback`)
19
+
}&scope=${encodeURIComponent(scope)}`;
20
+
console.log(`ClientId: ${clientId}`);
21
+
4
22
/**
5
23
* Create the OAuth client.
6
24
* @param db - The Deno KV instance for the database
···
11
29
throw new Error("PUBLIC_URL is not set");
12
30
}
13
31
14
-
const publicUrl = Deno.env.get("PUBLIC_URL");
15
-
const url = publicUrl || `http://127.0.0.1:8000`;
16
-
const enc = encodeURIComponent;
17
-
const clientId = publicUrl
18
-
? `${url}/oauth-client-metadata.json`
19
-
: `http://localhost?redirect_uri=${
20
-
enc(`${url}/api/oauth/callback`)
21
-
}&scope=${enc("atproto transition:generic transition:chat.bsky")}`;
22
-
console.log(`ClientId: ${clientId}`);
23
-
24
32
return new AtprotoOAuthClient({
25
33
clientMetadata: {
26
34
client_name: "Statusphere React App",
27
35
client_id: clientId,
28
36
client_uri: url,
29
37
redirect_uris: [`${url}/api/oauth/callback`],
30
-
scope: "atproto transition:generic transition:chat.bsky",
38
+
scope: scope,
31
39
grant_types: ["authorization_code", "refresh_token"],
32
40
response_types: ["code"],
33
41
application_type: "web",
···
36
44
},
37
45
stateStore: new StateStore(db),
38
46
sessionStore: new SessionStore(db),
47
+
didCache: undefined,
48
+
allowHttp: isDev,
49
+
plcDirectoryUrl: Deno.env.get("PLC_URL") ?? "https://plc.directory",
39
50
});
40
51
};
41
52
+4
-3
routes/api/oauth/initiate.ts
+4
-3
routes/api/oauth/initiate.ts
···
1
1
import { isValidHandle } from "npm:@atproto/syntax";
2
-
import { oauthClient } from "../../../lib/oauth/client.ts";
2
+
import { oauthClient, scope } from "../../../lib/oauth/client.ts";
3
3
import { define } from "../../../utils.ts";
4
4
5
5
function isValidUrl(url: string): boolean {
···
18
18
const handle = data.handle;
19
19
if (
20
20
typeof handle !== "string" ||
21
-
!(isValidHandle(handle) || isValidUrl(handle))
21
+
!(isValidHandle(handle) || isValidUrl(handle) ||
22
+
handle.startsWith("did:"))
22
23
) {
23
24
return new Response("Invalid Handle", { status: 400 });
24
25
}
···
26
27
// Initiate the OAuth flow
27
28
try {
28
29
const url = await oauthClient.authorize(handle, {
29
-
scope: "atproto transition:generic transition:chat.bsky",
30
+
scope,
30
31
});
31
32
return Response.json({ redirectUrl: url.toString() });
32
33
} catch (err) {
+35
routes/oauth-client-metadata.json/index.ts
+35
routes/oauth-client-metadata.json/index.ts
···
1
+
import { clientId, scope } from "../../lib/oauth/client.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
+
return Response.json({
11
+
client_name: "ATP Airport",
12
+
client_id: clientId,
13
+
client_uri: "https://atpairport.com",
14
+
redirect_uris: [
15
+
"https://atpairport.com/api/oauth/callback",
16
+
],
17
+
scope,
18
+
grant_types: [
19
+
"authorization_code",
20
+
"refresh_token",
21
+
],
22
+
response_types: [
23
+
"code",
24
+
],
25
+
application_type: "web",
26
+
token_endpoint_auth_method: "none",
27
+
dpop_bound_access_tokens: true,
28
+
}, {
29
+
status: 200,
30
+
headers: {
31
+
"Content-Type": "application/json",
32
+
},
33
+
});
34
+
},
35
+
});
-19
static/oauth-client-metadata.json
-19
static/oauth-client-metadata.json
···
1
-
{
2
-
"client_name": "ATP Airport",
3
-
"client_id": "https://atpairport.com/oauth-client-metadata.json",
4
-
"client_uri": "https://atpairport.com",
5
-
"redirect_uris": [
6
-
"https://atpairport.com/api/oauth/callback"
7
-
],
8
-
"scope": "atproto transition:generic transition:chat.bsky",
9
-
"grant_types": [
10
-
"authorization_code",
11
-
"refresh_token"
12
-
],
13
-
"response_types": [
14
-
"code"
15
-
],
16
-
"application_type": "web",
17
-
"token_endpoint_auth_method": "none",
18
-
"dpop_bound_access_tokens": true
19
-
}
tests/.yarn/install-state.gz
tests/.yarn/install-state.gz
This is a binary file and will not be displayed.
+1
-1
tests/e2e/migration.test.ts
+1
-1
tests/e2e/migration.test.ts
+3
-1
tests/utils/test-env.ts
+3
-1
tests/utils/test-env.ts
···
3
3
*/
4
4
5
5
import { Agent } from "@atproto/api";
6
-
import { TestPds, TestPlc } from "@atproto/dev-env";
6
+
import { TestBsky, TestPds, TestPlc } from "@atproto/dev-env";
7
7
import { ComAtprotoServerCreateAccount } from "@atproto/api";
8
8
import { SMTPServer, SMTPServerAddress } from "smtp-server";
9
9
import * as cheerio from "cheerio";
···
171
171
devMode: true,
172
172
emailSmtpUrl: `smtp://localhost:${SMTP_PORT}`,
173
173
emailFromAddress: `noreply@localhost:${SMTP_PORT}`,
174
+
bskyAppViewDid: "did:web:api.bsky.app",
174
175
});
175
176
176
177
const targetPds = await TestPds.create({
···
181
182
devMode: true,
182
183
emailSmtpUrl: `smtp://localhost:${SMTP_PORT}`,
183
184
emailFromAddress: `noreply@localhost:${SMTP_PORT}`,
185
+
bskyAppViewDid: "did:web:api.bsky.app",
184
186
});
185
187
186
188
return {