this repo has no description
1import type {
2 AuthMethod,
3 MigrationProgress,
4 OfflineInboundMigrationState,
5 OfflineInboundStep,
6 ServerDescription,
7} from "./types";
8import {
9 AtprotoClient,
10 base64UrlEncode,
11 createLocalClient,
12 prepareWebAuthnCreationOptions,
13} from "./atproto-client";
14import { api } from "../api";
15import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops";
16import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
17import { Secp256k1PrivateKeyExportable } from "@atcute/crypto";
18
19const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
20const MAX_AGE_MS = 24 * 60 * 60 * 1000;
21
22interface StoredOfflineMigrationState {
23 version: number;
24 step: OfflineInboundStep;
25 startedAt: string;
26 userDid: string;
27 carFileName: string;
28 carSizeBytes: number;
29 rotationKeyDidKey: string;
30 targetHandle: string;
31 targetEmail: string;
32 authMethod: AuthMethod;
33 passkeySetupToken?: string;
34 oldPdsUrl?: string;
35 plcUpdatedTemporarily?: boolean;
36 progress: {
37 accountCreated: boolean;
38 repoImported: boolean;
39 plcSigned: boolean;
40 activated: boolean;
41 };
42 lastError?: string;
43}
44
45function saveOfflineState(state: OfflineInboundMigrationState): void {
46 const stored: StoredOfflineMigrationState = {
47 version: 1,
48 step: state.step,
49 startedAt: new Date().toISOString(),
50 userDid: state.userDid,
51 carFileName: state.carFileName,
52 carSizeBytes: state.carSizeBytes,
53 rotationKeyDidKey: state.rotationKeyDidKey,
54 targetHandle: state.targetHandle,
55 targetEmail: state.targetEmail,
56 authMethod: state.authMethod,
57 passkeySetupToken: state.passkeySetupToken ?? undefined,
58 oldPdsUrl: state.oldPdsUrl ?? undefined,
59 plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined,
60 progress: {
61 accountCreated: state.progress.repoExported,
62 repoImported: state.progress.repoImported,
63 plcSigned: state.progress.plcSigned,
64 activated: state.progress.activated,
65 },
66 lastError: state.error ?? undefined,
67 };
68 try {
69 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored));
70 } catch { /* ignore localStorage errors */ }
71}
72
73function loadOfflineState(): StoredOfflineMigrationState | null {
74 try {
75 const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
76 if (!stored) return null;
77 const state = JSON.parse(stored) as StoredOfflineMigrationState;
78 if (state.version !== 1) {
79 clearOfflineState();
80 return null;
81 }
82 const startedAt = new Date(state.startedAt).getTime();
83 if (Date.now() - startedAt > MAX_AGE_MS) {
84 clearOfflineState();
85 return null;
86 }
87 return state;
88 } catch {
89 /* ignore parse errors */
90 clearOfflineState();
91 return null;
92 }
93}
94
95function clearOfflineState(): void {
96 try {
97 localStorage.removeItem(OFFLINE_STORAGE_KEY);
98 } catch { /* ignore localStorage errors */ }
99}
100
101export function hasPendingOfflineMigration(): boolean {
102 return loadOfflineState() !== null;
103}
104
105export function getOfflineResumeInfo(): {
106 step: OfflineInboundStep;
107 userDid: string;
108 targetHandle: string;
109} | null {
110 const state = loadOfflineState();
111 if (!state) return null;
112 return {
113 step: state.step,
114 userDid: state.userDid,
115 targetHandle: state.targetHandle,
116 };
117}
118
119export { clearOfflineState };
120
121function createInitialProgress(): MigrationProgress {
122 return {
123 repoExported: false,
124 repoImported: false,
125 blobsTotal: 0,
126 blobsMigrated: 0,
127 blobsFailed: [],
128 prefsMigrated: false,
129 plcSigned: false,
130 activated: false,
131 deactivated: false,
132 currentOperation: "",
133 };
134}
135
136export type OfflineInboundMigrationFlow = ReturnType<
137 typeof createOfflineInboundMigrationFlow
138>;
139
140export function createOfflineInboundMigrationFlow() {
141 let state = $state<OfflineInboundMigrationState>({
142 direction: "offline-inbound",
143 step: "welcome",
144 userDid: "",
145 carFile: null,
146 carFileName: "",
147 carSizeBytes: 0,
148 carNeedsReupload: false,
149 rotationKey: "",
150 rotationKeyDidKey: "",
151 oldPdsUrl: null,
152 targetHandle: "",
153 targetEmail: "",
154 targetPassword: "",
155 inviteCode: "",
156 authMethod: "password",
157 localAccessToken: null,
158 localRefreshToken: null,
159 passkeySetupToken: null,
160 generatedAppPassword: null,
161 generatedAppPasswordName: null,
162 emailVerifyToken: "",
163 progress: createInitialProgress(),
164 error: null,
165 plcUpdatedTemporarily: false,
166 });
167
168 let localServerInfo: ServerDescription | null = null;
169 let userRotationKeypair: KeypairInfo | null = null;
170 let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null;
171
172 function setStep(step: OfflineInboundStep) {
173 state.step = step;
174 state.error = null;
175 if (step !== "success") {
176 saveOfflineState(state);
177 }
178 }
179
180 function setError(error: string | null) {
181 state.error = error;
182 saveOfflineState(state);
183 }
184
185 function setProgress(updates: Partial<MigrationProgress>) {
186 state.progress = { ...state.progress, ...updates };
187 saveOfflineState(state);
188 }
189
190 async function loadLocalServerInfo(): Promise<ServerDescription> {
191 if (!localServerInfo) {
192 const client = createLocalClient();
193 localServerInfo = await client.describeServer();
194 }
195 return localServerInfo;
196 }
197
198 async function checkHandleAvailability(handle: string): Promise<boolean> {
199 const client = createLocalClient();
200 try {
201 await client.resolveHandle(handle);
202 return false;
203 } catch {
204 return true;
205 }
206 }
207
208 async function validateRotationKey(): Promise<boolean> {
209 if (!state.userDid || !state.rotationKey) {
210 throw new Error("DID and rotation key are required");
211 }
212
213 try {
214 userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim());
215 const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid);
216 const currentRotationKeys = lastOperation.rotationKeys || [];
217
218 if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) {
219 state.rotationKeyDidKey = "";
220 return false;
221 }
222
223 state.rotationKeyDidKey = userRotationKeypair.didPublicKey;
224
225 const pdsService = lastOperation.services?.atproto_pds;
226 if (pdsService?.endpoint) {
227 state.oldPdsUrl = pdsService.endpoint;
228 console.log(
229 "[offline-migration] Captured old PDS URL:",
230 state.oldPdsUrl,
231 );
232 } else {
233 console.warn(
234 "[offline-migration] No PDS service endpoint found in PLC document",
235 );
236 console.log(
237 "[offline-migration] PLC services:",
238 JSON.stringify(lastOperation.services),
239 );
240 }
241
242 saveOfflineState(state);
243 return true;
244 } catch (e) {
245 throw new Error(`Failed to parse rotation key: ${(e as Error).message}`);
246 }
247 }
248
249 async function prepareTempCredentials(): Promise<string> {
250 if (!userRotationKeypair) {
251 throw new Error("Rotation key not validated");
252 }
253
254 setProgress({ currentOperation: "Preparing temporary credentials..." });
255
256 tempVerificationKeypair = await Secp256k1PrivateKeyExportable
257 .createKeypair();
258 const tempVerificationPublicKey = await tempVerificationKeypair
259 .exportPublicKey("did");
260
261 const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc(
262 state.userDid,
263 );
264 const prevCid = base.cid;
265
266 setProgress({ currentOperation: "Updating DID document temporarily..." });
267
268 const localPdsUrl = globalThis.location.origin;
269 await plcOps.signAndPublishNewOp(
270 state.userDid,
271 userRotationKeypair.keypair,
272 lastOperation.alsoKnownAs || [],
273 [userRotationKeypair.didPublicKey],
274 localPdsUrl,
275 tempVerificationPublicKey,
276 prevCid,
277 );
278
279 state.plcUpdatedTemporarily = true;
280 saveOfflineState(state);
281
282 const serverInfo = await loadLocalServerInfo();
283 const serviceAuthToken = await plcOps.createServiceAuthToken(
284 state.userDid,
285 serverInfo.did,
286 tempVerificationKeypair as unknown as PrivateKey,
287 "com.atproto.server.createAccount",
288 );
289
290 return serviceAuthToken;
291 }
292
293 async function createPasswordAccount(
294 serviceAuthToken: string,
295 ): Promise<void> {
296 setProgress({ currentOperation: "Creating account on new PDS..." });
297
298 const serverInfo = await loadLocalServerInfo();
299 const fullHandle = state.targetHandle.includes(".")
300 ? state.targetHandle
301 : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
302
303 const createResult = await api.createAccountWithServiceAuth(
304 serviceAuthToken,
305 {
306 did: state.userDid,
307 handle: fullHandle,
308 email: state.targetEmail,
309 password: state.targetPassword,
310 inviteCode: state.inviteCode || undefined,
311 },
312 );
313
314 state.targetHandle = fullHandle;
315 state.localAccessToken = createResult.accessJwt;
316 state.localRefreshToken = createResult.refreshJwt;
317 setProgress({ repoExported: true });
318 }
319
320 async function createPasskeyAccount(serviceAuthToken: string): Promise<void> {
321 setProgress({ currentOperation: "Creating passkey account on new PDS..." });
322
323 const serverInfo = await loadLocalServerInfo();
324 const fullHandle = state.targetHandle.includes(".")
325 ? state.targetHandle
326 : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
327
328 const createResult = await api.createPasskeyAccount({
329 did: state.userDid,
330 handle: fullHandle,
331 email: state.targetEmail,
332 inviteCode: state.inviteCode || undefined,
333 }, serviceAuthToken);
334
335 state.targetHandle = fullHandle;
336 state.passkeySetupToken = createResult.setupToken;
337 setProgress({ repoExported: true });
338 saveOfflineState(state);
339 }
340
341 async function signFinalPlcOperation(): Promise<void> {
342 if (!userRotationKeypair || !state.localAccessToken) {
343 throw new Error("Prerequisites not met for PLC signing");
344 }
345
346 setProgress({ currentOperation: "Finalizing DID document..." });
347
348 const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid);
349 const prevCid = base.cid;
350
351 const credentials = await api.getRecommendedDidCredentials(
352 state.localAccessToken,
353 );
354
355 await plcOps.signPlcOperationWithCredentials(
356 state.userDid,
357 userRotationKeypair.keypair,
358 {
359 rotationKeys: credentials.rotationKeys,
360 alsoKnownAs: credentials.alsoKnownAs,
361 verificationMethods: credentials.verificationMethods,
362 services: credentials.services,
363 },
364 [userRotationKeypair.didPublicKey],
365 prevCid,
366 );
367
368 setProgress({ plcSigned: true });
369 }
370
371 async function importRepository(): Promise<void> {
372 if (!state.carFile || !state.localAccessToken) {
373 throw new Error("CAR file and access token are required");
374 }
375
376 setProgress({ currentOperation: "Importing repository..." });
377 await api.importRepo(state.localAccessToken, state.carFile);
378 setProgress({ repoImported: true });
379 }
380
381 async function migrateBlobs(): Promise<void> {
382 if (!state.localAccessToken) {
383 throw new Error("Access token required");
384 }
385
386 const localClient = createLocalClient();
387 localClient.setAccessToken(state.localAccessToken);
388
389 if (state.oldPdsUrl) {
390 setProgress({
391 currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`,
392 });
393 } else {
394 setProgress({
395 currentOperation: "No source PDS URL available for blob migration",
396 });
397 }
398
399 const sourceClient = state.oldPdsUrl
400 ? new AtprotoClient(state.oldPdsUrl)
401 : null;
402
403 const result = await migrateBlobsUtil(
404 localClient,
405 sourceClient,
406 state.userDid,
407 setProgress,
408 );
409
410 state.progress.blobsFailed = result.failed;
411 state.progress.blobsTotal = result.total;
412 state.progress.blobsMigrated = result.migrated;
413
414 if (result.total === 0) {
415 setProgress({ currentOperation: "No blobs to migrate" });
416 } else if (result.sourceUnreachable) {
417 setProgress({
418 currentOperation:
419 `Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`,
420 });
421 } else if (result.failed.length > 0) {
422 setProgress({
423 currentOperation:
424 `${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`,
425 });
426 } else {
427 setProgress({
428 currentOperation: `All ${result.migrated} blobs migrated successfully`,
429 });
430 }
431 }
432
433 async function activateAccount(): Promise<void> {
434 if (!state.localAccessToken) {
435 throw new Error("Access token required");
436 }
437
438 setProgress({ currentOperation: "Activating account..." });
439 await api.activateAccount(state.localAccessToken);
440 setProgress({ activated: true });
441 }
442
443 async function submitEmailVerifyToken(token: string): Promise<void> {
444 state.emailVerifyToken = token;
445 setError(null);
446
447 try {
448 await api.verifyMigrationEmail(token, state.targetEmail);
449
450 if (state.authMethod === "passkey") {
451 setStep("passkey-setup");
452 } else {
453 const session = await api.createSession(
454 state.targetEmail,
455 state.targetPassword,
456 );
457 state.localAccessToken = session.accessJwt;
458 state.localRefreshToken = session.refreshJwt;
459 saveOfflineState(state);
460
461 setStep("plc-signing");
462 await signFinalPlcOperation();
463
464 setStep("finalizing");
465 await activateAccount();
466
467 cleanup();
468 setStep("success");
469 }
470 } catch (e) {
471 const err = e as Error & { error?: string };
472 setError(err.message || err.error || "Email verification failed");
473 }
474 }
475
476 async function resendEmailVerification(): Promise<void> {
477 await api.resendMigrationVerification(state.targetEmail);
478 }
479
480 let checkingEmailVerification = false;
481
482 async function checkEmailVerifiedAndProceed(): Promise<boolean> {
483 if (checkingEmailVerification) return false;
484 if (state.authMethod === "passkey") return false;
485
486 checkingEmailVerification = true;
487 try {
488 const { verified } = await api.checkEmailVerified(state.targetEmail);
489 if (!verified) return false;
490
491 const session = await api.createSession(
492 state.targetEmail,
493 state.targetPassword,
494 );
495 state.localAccessToken = session.accessJwt;
496 state.localRefreshToken = session.refreshJwt;
497 saveOfflineState(state);
498
499 setStep("plc-signing");
500 await signFinalPlcOperation();
501
502 setStep("finalizing");
503 await activateAccount();
504
505 cleanup();
506 setStep("success");
507 return true;
508 } catch {
509 return false;
510 } finally {
511 checkingEmailVerification = false;
512 }
513 }
514
515 async function startPasskeyRegistration(): Promise<{ options: unknown }> {
516 if (!state.passkeySetupToken) {
517 throw new Error("No passkey setup token");
518 }
519
520 return api.startPasskeyRegistrationForSetup(
521 state.userDid,
522 state.passkeySetupToken,
523 );
524 }
525
526 async function registerPasskey(passkeyName?: string): Promise<void> {
527 if (!state.passkeySetupToken) {
528 throw new Error("No passkey setup token");
529 }
530
531 if (!globalThis.PublicKeyCredential) {
532 throw new Error("Passkeys are not supported in this browser");
533 }
534
535 const { options } = await startPasskeyRegistration();
536
537 const publicKeyOptions = prepareWebAuthnCreationOptions(
538 options as { publicKey: Record<string, unknown> },
539 );
540 const credential = await navigator.credentials.create({
541 publicKey: publicKeyOptions,
542 });
543
544 if (!credential) {
545 throw new Error("Passkey creation was cancelled");
546 }
547
548 const publicKeyCredential = credential as PublicKeyCredential;
549 const response = publicKeyCredential
550 .response as AuthenticatorAttestationResponse;
551
552 const credentialData = {
553 id: publicKeyCredential.id,
554 rawId: base64UrlEncode(publicKeyCredential.rawId),
555 type: publicKeyCredential.type,
556 response: {
557 clientDataJSON: base64UrlEncode(response.clientDataJSON),
558 attestationObject: base64UrlEncode(response.attestationObject),
559 },
560 };
561
562 const result = await api.completePasskeySetup(
563 state.userDid,
564 state.passkeySetupToken,
565 credentialData,
566 passkeyName,
567 );
568
569 state.generatedAppPassword = result.appPassword;
570 state.generatedAppPasswordName = result.appPasswordName;
571
572 const session = await api.createSession(
573 state.targetEmail,
574 result.appPassword,
575 );
576 state.localAccessToken = session.accessJwt;
577 state.localRefreshToken = session.refreshJwt;
578 saveOfflineState(state);
579
580 setStep("app-password");
581 }
582
583 async function proceedFromAppPassword(): Promise<void> {
584 setStep("plc-signing");
585 await signFinalPlcOperation();
586
587 setStep("finalizing");
588 await activateAccount();
589
590 cleanup();
591 setStep("success");
592 }
593
594 function cleanup(): void {
595 clearOfflineState();
596 userRotationKeypair = null;
597 tempVerificationKeypair = null;
598 state.rotationKey = "";
599 }
600
601 async function runMigration(): Promise<void> {
602 try {
603 setStep("creating");
604
605 const serviceAuthToken = await prepareTempCredentials();
606
607 if (state.authMethod === "passkey") {
608 await createPasskeyAccount(serviceAuthToken);
609 } else {
610 await createPasswordAccount(serviceAuthToken);
611 }
612
613 setStep("importing");
614 await importRepository();
615
616 setStep("migrating-blobs");
617 await migrateBlobs();
618
619 if (
620 state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0
621 ) {
622 await new Promise((resolve) => setTimeout(resolve, 3000));
623 }
624
625 setStep("email-verify");
626 } catch (e) {
627 setError((e as Error).message);
628 setStep("error");
629 }
630 }
631
632 function reset() {
633 clearOfflineState();
634 userRotationKeypair = null;
635 tempVerificationKeypair = null;
636 state = {
637 direction: "offline-inbound",
638 step: "welcome",
639 userDid: "",
640 carFile: null,
641 carFileName: "",
642 carSizeBytes: 0,
643 carNeedsReupload: false,
644 rotationKey: "",
645 rotationKeyDidKey: "",
646 oldPdsUrl: null,
647 targetHandle: "",
648 targetEmail: "",
649 targetPassword: "",
650 inviteCode: "",
651 authMethod: "password",
652 localAccessToken: null,
653 localRefreshToken: null,
654 passkeySetupToken: null,
655 generatedAppPassword: null,
656 generatedAppPasswordName: null,
657 emailVerifyToken: "",
658 progress: createInitialProgress(),
659 error: null,
660 plcUpdatedTemporarily: false,
661 };
662 localServerInfo = null;
663 }
664
665 function tryResume(): boolean {
666 const stored = loadOfflineState();
667 if (!stored) return false;
668
669 state.userDid = stored.userDid;
670 state.carFileName = stored.carFileName;
671 state.carSizeBytes = stored.carSizeBytes;
672 state.rotationKeyDidKey = stored.rotationKeyDidKey;
673 state.targetHandle = stored.targetHandle;
674 state.targetEmail = stored.targetEmail;
675 state.authMethod = stored.authMethod ?? "password";
676 state.passkeySetupToken = stored.passkeySetupToken ?? null;
677 state.oldPdsUrl = stored.oldPdsUrl ?? null;
678 state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false;
679 state.step = stored.step;
680 state.progress.repoExported = stored.progress.accountCreated;
681 state.progress.repoImported = stored.progress.repoImported;
682 state.progress.plcSigned = stored.progress.plcSigned;
683 state.progress.activated = stored.progress.activated;
684 state.error = stored.lastError ?? null;
685
686 if (stored.carFileName && stored.carSizeBytes > 0) {
687 state.carNeedsReupload = true;
688 }
689
690 return true;
691 }
692
693 function getLocalSession():
694 | { accessJwt: string; did: string; handle: string }
695 | null {
696 if (!state.localAccessToken) return null;
697 return {
698 accessJwt: state.localAccessToken,
699 did: state.userDid,
700 handle: state.targetHandle,
701 };
702 }
703
704 return {
705 get state() {
706 return state;
707 },
708 getLocalSession,
709 setStep,
710 setError,
711 setProgress,
712 loadLocalServerInfo,
713 checkHandleAvailability,
714 validateRotationKey,
715 runMigration,
716 submitEmailVerifyToken,
717 resendEmailVerification,
718 checkEmailVerifiedAndProceed,
719 startPasskeyRegistration,
720 registerPasskey,
721 proceedFromAppPassword,
722 reset,
723 tryResume,
724 clearOfflineState,
725 setUserDid(did: string) {
726 state.userDid = did;
727 saveOfflineState(state);
728 },
729 setCarFile(file: Uint8Array, fileName: string) {
730 state.carFile = file;
731 state.carFileName = fileName;
732 state.carSizeBytes = file.length;
733 state.carNeedsReupload = false;
734 saveOfflineState(state);
735 },
736 setRotationKey(key: string) {
737 state.rotationKey = key;
738 },
739 setTargetHandle(handle: string) {
740 state.targetHandle = handle;
741 saveOfflineState(state);
742 },
743 setTargetEmail(email: string) {
744 state.targetEmail = email;
745 saveOfflineState(state);
746 },
747 setTargetPassword(password: string) {
748 state.targetPassword = password;
749 },
750 setInviteCode(code: string) {
751 state.inviteCode = code;
752 },
753 setAuthMethod(method: AuthMethod) {
754 state.authMethod = method;
755 saveOfflineState(state);
756 },
757 updateField<K extends keyof OfflineInboundMigrationState>(
758 field: K,
759 value: OfflineInboundMigrationState[K],
760 ) {
761 state[field] = value;
762 saveOfflineState(state);
763 },
764 };
765}