objective categorical abstract machine language personal data server

Account migration page

futur.blue 5d2164aa 343e906a

verified
+3
bin/main.ml
··· 40 40 ; ( get 41 41 , "/account/signup/check-handle" 42 42 , Api.Account_.Signup.check_handle_handler ) 43 + ; (get, "/account/migrate", Api.Account_.Migrate.get_handler) 44 + ; (post, "/account/migrate", Api.Account_.Migrate.post_handler) 43 45 ; (post, "/account/switch", Api.Account_.Login.switch_account_handler) 44 46 ; (get, "/account/logout", Api.Account_.Logout.handler) 45 47 ; (* admin ui *) ··· 203 205 204 206 let serve () = 205 207 Printexc.record_backtrace true ; 208 + Printexc.register_printer Errors.printer ; 206 209 Dream.initialize_log ~level:Env.log_level () ; 207 210 List.iter (fun src -> 208 211 match Logs.Src.name src with
+1
frontend/client/Router.mlx
··· 17 17 ; {path= "/oauth/authorize"; template= (module OauthAuthorizePage)} 18 18 ; {path= "/account/login"; template= (module LoginPage)} 19 19 ; {path= "/account/signup"; template= (module SignupPage)} 20 + ; {path= "/account/migrate"; template= (module MigratePage)} 20 21 ; {path= "/account"; template= (module AccountPage)} 21 22 ; {path= "/account/permissions"; template= (module AccountPermissionsPage)} 22 23 ; {path= "/admin/login"; template= (module AdminLoginPage)}
+5 -3
frontend/src/templates/AccountPermissionsPage.mlx
··· 89 89 <ul className="space-y-2"> 90 90 ( List.map 91 91 (fun (app : authorized_app) -> 92 - let name = Option.value app.client_name ~default:app.client_host in 92 + let name = 93 + Option.value app.client_name ~default:app.client_host 94 + in 93 95 <li key=app.client_id className="flex items-center gap-x-2"> 94 96 <span 95 97 title=name ··· 174 176 formMethod="post" 175 177 name="action" 176 178 value="sign_out_device" 177 - className="text-mist-100 underline whitespace-nowrap \ 178 - hover:text-mana-100"> 179 + className="text-mist-100 underline \ 180 + whitespace-nowrap hover:text-mana-100"> 179 181 (string "sign out") 180 182 </button> 181 183 </form>
+17 -7
frontend/src/templates/LoginPage.mlx
··· 33 33 </Button> 34 34 </form> 35 35 <span className="text-sm text-mist-100"> 36 - (string "or ") 37 - <a 38 - href="/account/signup" 39 - className="text-mana-100 underline hover:text-mana-200"> 40 - (string "create an account") 41 - </a> 42 - (string ".") 36 + (string "or: ") 37 + <ul className="mt-1 pl-2"> 38 + <li className="mb-1"> 39 + <a 40 + className="text-mana-100 underline hover:text-mana-200" 41 + href="/account/signup"> 42 + (string "create an account") 43 + </a> 44 + </li> 45 + <li> 46 + <a 47 + className="text-mana-100 underline hover:text-mana-200" 48 + href="/account/migrate"> 49 + (string "migrate from another PDS") 50 + </a> 51 + </li> 52 + </ul> 43 53 </span> 44 54 </main>
+636
frontend/src/templates/MigratePage.mlx
··· 1 + [@@@ocaml.warning "-26-27"] 2 + 3 + open Melange_json.Primitives 4 + open React 5 + 6 + type migration_step = 7 + | EnterCredentials 8 + | ResumeAvailable 9 + | Enter2FA 10 + | ImportingData 11 + | EnterPlcToken 12 + | Complete 13 + | Error 14 + 15 + let step_of_string = function 16 + | "enter_credentials" -> 17 + EnterCredentials 18 + | "resume_available" -> 19 + ResumeAvailable 20 + | "enter_2fa" -> 21 + Enter2FA 22 + | "importing_data" -> 23 + ImportingData 24 + | "enter_plc_token" -> 25 + EnterPlcToken 26 + | "complete" -> 27 + Complete 28 + | "error" -> 29 + Error 30 + | _ -> 31 + EnterCredentials 32 + 33 + let step_to_label = function 34 + | EnterCredentials -> 35 + "enter credentials" 36 + | ResumeAvailable -> 37 + "resume migration" 38 + | Enter2FA -> 39 + "two-factor authentication" 40 + | ImportingData -> 41 + "importing data" 42 + | EnterPlcToken -> 43 + "confirm identity" 44 + | Complete -> 45 + "migration complete" 46 + | Error -> 47 + "error" 48 + 49 + let step_to_number = function 50 + | EnterCredentials | ResumeAvailable | Enter2FA -> 51 + 0 52 + | ImportingData -> 53 + 1 54 + | EnterPlcToken -> 55 + 2 56 + | Complete | Error -> 57 + 3 58 + 59 + let total_steps = 3 60 + 61 + module ProgressIndicator = struct 62 + let[@react.component] make ~step_number ~label () = 63 + <div className="mb-6"> 64 + <div className="flex items-center gap-1 text-sm text-mist-100 mb-2"> 65 + <span> 66 + (string 67 + ( "step " ^ string_of_int step_number ^ " of " 68 + ^ string_of_int total_steps ^ ": " ) ) 69 + </span> 70 + <span className="font-medium text-mana-100">(string label)</span> 71 + </div> 72 + <div 73 + className="w-full bg-mist-60 rounded-full h-3" 74 + style=(ReactDOM.Style.make 75 + ~boxShadow:"0 4px 8px 0 rgba(115, 117, 121, 0.30) inset" () )> 76 + <div 77 + className="bg-mana-100 shadow-elixir h-3 rounded-full transition-all \ 78 + duration-300" 79 + style=(ReactDOM.Style.make 80 + ~width:(string_of_int (step_number * 100 / total_steps) ^ "%") 81 + () ) 82 + /> 83 + </div> 84 + </div> 85 + end 86 + 87 + module AlertMessage = struct 88 + let[@react.component] make ~message ~kind () = 89 + let bg_class, text_class, icon = 90 + match kind with 91 + | `Error -> 92 + ( "bg-phoenix-100/10 border-phoenix-100/30" 93 + , "text-phoenix-100" 94 + , <CircleAlertIcon className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" /> ) 95 + | `Success -> 96 + ( "bg-mana-100/10 border-mana-100/30" 97 + , "text-mana-100" 98 + , <CheckmarkIcon className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" /> ) 99 + in 100 + <div className=("mb-4 p-3 border rounded-lg " ^ bg_class)> 101 + <span className=("inline-flex items-start text-sm " ^ text_class)> 102 + icon <span>(string message)</span> 103 + </span> 104 + </div> 105 + end 106 + 107 + module LoadingOverlay = struct 108 + type loading_step = 109 + { label: string 110 + ; message: string 111 + ; duration: int (* ms before moving to next step *) } 112 + 113 + let steps = 114 + [| { label= "authenticating" 115 + ; message= "Logging into your current PDS..." 116 + ; duration= 2000 (* fake step *) } 117 + ; { label= "creating account" 118 + ; message= "Creating your account on this PDS..." 119 + ; duration= 2000 (* fake step *) } 120 + ; { label= "importing repository" 121 + ; message= "Importing your repository data..." 122 + ; duration= 0 (* stays here until page loads *) } |] 123 + 124 + let[@react.component] make () = 125 + let step_index, set_step_index = React.useState (fun () -> 0) in 126 + (* advance through steps on a timer *) 127 + React.useEffect1 128 + (fun () -> 129 + let current = steps.(step_index) in 130 + if current.duration > 0 && step_index < Array.length steps - 1 then 131 + let timer_id = 132 + Js.Global.setTimeout 133 + ~f:(fun () -> set_step_index (fun i -> i + 1)) 134 + current.duration 135 + in 136 + Some (fun () -> Js.Global.clearTimeout timer_id) 137 + else None ) 138 + [|step_index|] ; 139 + let current = steps.(step_index) in 140 + let step_number = step_index + 1 in 141 + <div> 142 + <div className="mb-6"> 143 + <div className="flex items-center gap-1 text-sm text-mist-100 mb-2"> 144 + <span> 145 + (string 146 + ( "step " ^ string_of_int step_number ^ " of " 147 + ^ string_of_int total_steps ^ ": " ) ) 148 + </span> 149 + <span className="font-medium text-mana-100"> 150 + (string current.label) 151 + </span> 152 + </div> 153 + <div 154 + className="w-full bg-mist-60 rounded-full h-3" 155 + style=(ReactDOM.Style.make 156 + ~boxShadow:"0 4px 8px 0 rgba(115, 117, 121, 0.30) inset" () )> 157 + <div 158 + className="bg-mana-100 shadow-elixir h-3 rounded-full \ 159 + transition-all duration-500" 160 + style=(ReactDOM.Style.make 161 + ~width: 162 + (string_of_int (step_number * 100 / total_steps) ^ "%") 163 + () ) 164 + /> 165 + </div> 166 + </div> 167 + <div className="flex flex-col items-center py-8"> 168 + <div 169 + className="animate-spin w-8 h-8 border-2 border-mana-100 \ 170 + border-t-transparent rounded-full mb-4" 171 + /> 172 + <p className="text-mist-100 text-center">(string current.message)</p> 173 + </div> 174 + </div> 175 + end 176 + 177 + module DidDisplay = struct 178 + let[@react.component] make ~did () = 179 + <p className="text-xs text-mist-80 mb-4"> 180 + (string "Account DID: ") 181 + <code className="bg-mist-60/50 px-1 rounded">(string did)</code> 182 + </p> 183 + end 184 + 185 + module CredentialsForm = struct 186 + let[@react.component] make ~csrf_token ~invite_required () = 187 + let is_submitting, set_is_submitting = React.useState (fun () -> false) in 188 + let handle_submit _ = set_is_submitting (fun _ -> true) in 189 + if is_submitting then <LoadingOverlay /> 190 + else 191 + array 192 + [| <p key="desc" className="w-full text-balance text-mist-100 mb-4"> 193 + (string 194 + "Migrate your existing atproto account to this PDS. You'll \ 195 + need your account credentials from your current PDS." ) 196 + </p> 197 + ; <form 198 + key="form" 199 + className="w-full flex flex-col gap-y-3" 200 + onSubmit=handle_submit> 201 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 202 + <input type_="hidden" name="action" value="start_migration" /> 203 + ( if invite_required then 204 + <Input 205 + name="invite_code" 206 + type_="text" 207 + label="Invite code" 208 + placeholder="a1b2c3d4" 209 + required=true 210 + showIndicator=false /> 211 + else null ) 212 + <Input 213 + name="identifier" 214 + type_="text" 215 + label="Existing handle or DID" 216 + placeholder="you.bsky.social or did:plc:..." 217 + required=true 218 + showIndicator=false 219 + /> 220 + <Input 221 + name="password" 222 + type_="password" 223 + label="Existing password" 224 + required=true 225 + showIndicator=false 226 + /> 227 + <p className="text-xs text-mist-80 -mt-1"> 228 + (string 229 + "Use your actual account password, not an app password. Your \ 230 + password is only used to log into your current PDS and is \ 231 + never stored." ) 232 + </p> 233 + <Button type_="submit" formMethod="post" className="mt-2"> 234 + (string "start migration") 235 + </Button> 236 + </form> 237 + ; <div key="footer" className="mt-4"> 238 + <span className="text-sm text-mist-100"> 239 + (string "Don't have an account? ") 240 + <a 241 + href="/account/signup" 242 + className="text-mana-100 underline hover:text-mana-200"> 243 + (string "sign up") 244 + </a> 245 + (string " instead.") 246 + </span> 247 + </div> |] 248 + end 249 + 250 + module ResumeForm = struct 251 + let[@react.component] make ~csrf_token ~did () = 252 + let is_submitting, set_is_submitting = React.useState (fun () -> false) in 253 + let handle_submit _ = set_is_submitting (fun _ -> true) in 254 + if is_submitting then <LoadingOverlay /> 255 + else 256 + <div> 257 + <div 258 + className="bg-mana-100/10 border border-mana-100/30 rounded-lg p-4 \ 259 + mb-4"> 260 + <div className="flex items-start gap-3"> 261 + <CheckmarkIcon 262 + className="w-5 h-5 text-mana-100 mt-0.5 flex-shrink-0" 263 + /> 264 + <div> 265 + <h3 className="font-medium text-mana-100 mb-1"> 266 + (string "Previous migration found") 267 + </h3> 268 + <p className="text-sm text-mist-100"> 269 + (string 270 + "An incomplete migration exists for this account. Enter \ 271 + your credentials again to continue where you left off." ) 272 + </p> 273 + </div> 274 + </div> 275 + </div> 276 + (match did with Some d -> <DidDisplay did=d /> | None -> null) 277 + <form className="w-full flex flex-col gap-y-3" onSubmit=handle_submit> 278 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 279 + <input type_="hidden" name="action" value="resume_migration" /> 280 + <Input 281 + name="identifier" 282 + type_="text" 283 + label="Handle or DID" 284 + placeholder="you.bsky.social or did:plc:..." 285 + required=true 286 + showIndicator=false 287 + /> 288 + <Input 289 + name="password" 290 + type_="password" 291 + label="Password" 292 + required=true 293 + showIndicator=false 294 + /> 295 + <p className="text-xs text-mist-80 -mt-1"> 296 + (string 297 + "Enter your credentials from your original PDS to resume the \ 298 + migration." ) 299 + </p> 300 + <Button type_="submit" formMethod="post" className="mt-2"> 301 + (string "resume migration") 302 + </Button> 303 + </form> 304 + <div className="mt-4"> 305 + <a 306 + href="/account/migrate" 307 + className="text-sm text-mist-100 underline hover:text-mana-100"> 308 + (string "Start a new migration instead") 309 + </a> 310 + </div> 311 + </div> 312 + end 313 + 314 + module TwoFactorForm = struct 315 + let[@react.component] make ~csrf_token ~identifier ~old_pds ~invite_code () = 316 + let is_submitting, set_is_submitting = React.useState (fun () -> false) in 317 + let handle_submit _ = set_is_submitting (fun _ -> true) in 318 + if is_submitting then <LoadingOverlay /> 319 + else 320 + <div> 321 + <p className="text-mist-100 mb-4"> 322 + (string 323 + "Your account requires two-factor authentication. Check your \ 324 + email for a sign-in code." ) 325 + </p> 326 + <form className="w-full flex flex-col gap-y-3" onSubmit=handle_submit> 327 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 328 + <input type_="hidden" name="action" value="submit_2fa" /> 329 + <input type_="hidden" name="identifier" value=identifier /> 330 + <input type_="hidden" name="old_pds" value=old_pds /> 331 + ( match invite_code with 332 + | Some code -> 333 + <input type_="hidden" name="invite_code" value=code /> 334 + | None -> 335 + null ) 336 + <Input 337 + name="password" 338 + type_="password" 339 + label="Password" 340 + required=true 341 + showIndicator=false 342 + /> 343 + <Input 344 + name="auth_factor_token" 345 + type_="text" 346 + label="Authentication code" 347 + placeholder="123456" 348 + required=true 349 + showIndicator=false 350 + /> 351 + <p className="text-xs text-mist-80 -mt-1"> 352 + (string "Enter your password and the code from your email.") 353 + </p> 354 + <Button type_="submit" formMethod="post" className="mt-2"> 355 + (string "continue") 356 + </Button> 357 + </form> 358 + <div className="mt-4"> 359 + <a 360 + href="/account/migrate" 361 + className="text-sm text-mist-100 underline hover:text-mana-100"> 362 + (string "Start over") 363 + </a> 364 + </div> 365 + </div> 366 + end 367 + 368 + module BlobProgress = struct 369 + let[@react.component] make ~csrf_token ~did ~blobs_imported ~blobs_failed () = 370 + <div> 371 + <div className="flex flex-col items-center py-6"> 372 + <div 373 + className="animate-spin w-8 h-8 border-2 border-mana-100 \ 374 + border-t-transparent rounded-full mb-4" 375 + /> 376 + <p className="text-mist-100 text-center mb-1"> 377 + (string "Importing media...") 378 + </p> 379 + <p className="text-sm text-mist-80 text-center"> 380 + (string (Printf.sprintf "%d blobs imported" blobs_imported)) 381 + ( if blobs_failed > 0 then 382 + <span className="text-phoenix-100"> 383 + (string (Printf.sprintf " (%d failed)" blobs_failed)) 384 + </span> 385 + else null ) 386 + </p> 387 + </div> 388 + <form className="flex justify-center"> 389 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 390 + <input type_="hidden" name="action" value="continue_blobs" /> 391 + ( match did with 392 + | Some d -> 393 + <input type_="hidden" name="did" value=d /> 394 + | None -> 395 + null ) 396 + <Button type_="submit" formMethod="post"> 397 + (string "continue importing") 398 + </Button> 399 + </form> 400 + </div> 401 + end 402 + 403 + module PlcTokenForm = struct 404 + let[@react.component] make ~csrf_token ~did () = 405 + <div> 406 + <p className="text-mist-100 mb-4"> 407 + (string 408 + "Your account has been created and your data has been imported. To \ 409 + complete the migration, enter the confirmation code that was sent \ 410 + to your email." ) 411 + </p> 412 + <form className="w-full flex flex-col gap-y-3"> 413 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 414 + <input type_="hidden" name="action" value="submit_plc_token" /> 415 + <Input 416 + name="plc_token" 417 + type_="text" 418 + label="Confirmation code" 419 + placeholder="A1B2C-3D4E5" 420 + required=true 421 + showIndicator=false 422 + /> 423 + <p className="text-xs text-mist-80 -mt-1"> 424 + (string 425 + "This code was sent to the email address belonging to your \ 426 + account on your old PDS." ) 427 + </p> 428 + <Button type_="submit" formMethod="post" className="mt-2"> 429 + (string "complete migration") 430 + </Button> 431 + </form> 432 + <form className="mt-3"> 433 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 434 + <input type_="hidden" name="action" value="resend_plc_token" /> 435 + <button 436 + type_="submit" 437 + formMethod="post" 438 + className="text-sm text-mist-100 underline hover:text-mana-100"> 439 + (string "Resend confirmation code") 440 + </button> 441 + </form> 442 + </div> 443 + end 444 + 445 + module CompleteView = struct 446 + let[@react.component] make ~handle ~blobs_failed ~old_account_deactivated 447 + ~old_account_deactivation_error () = 448 + <div className="py-4"> 449 + <div className="flex items-center gap-3 mb-4"> 450 + <div 451 + className="w-10 h-10 bg-mana-100/20 rounded-full flex items-center \ 452 + justify-center"> 453 + <CheckmarkIcon className="w-5 h-5 text-mana-100" /> 454 + </div> 455 + <div> 456 + <h2 className="text-lg font-serif text-mana-100"> 457 + (string "Migration complete!") 458 + </h2> 459 + <p className="text-sm text-mist-100"> 460 + (string "Your account") 461 + ( match handle with 462 + | Some h -> 463 + <span className="font-medium text-mana-100"> 464 + (string (" @" ^ h)) 465 + </span> 466 + | None -> 467 + null ) 468 + (string " is now on this PDS.") 469 + </p> 470 + </div> 471 + </div> 472 + ( if old_account_deactivated then 473 + <p className="mb-4 text-sm text-mist-80"> 474 + (string 475 + "Your account on your old PDS has been deactivated \ 476 + automatically." ) 477 + </p> 478 + else 479 + match old_account_deactivation_error with 480 + | Some err -> 481 + <div 482 + className="mb-4 p-3 bg-phoenix-100/10 border \ 483 + border-phoenix-100/30 rounded-lg"> 484 + <div className="flex items-start gap-2"> 485 + <CircleAlertIcon 486 + className="w-4 h-4 text-phoenix-100 mt-0.5 flex-shrink-0" 487 + /> 488 + <div> 489 + <p className="text-sm text-phoenix-100 font-medium"> 490 + (string "Could not deactivate your old account") 491 + </p> 492 + <p className="text-xs text-phoenix-100/80 mt-1"> 493 + (string err) 494 + </p> 495 + <p className="text-sm text-mist-100 mt-2"> 496 + (string 497 + "You may need to log into your account on your old \ 498 + PDS to deactivate it there." ) 499 + </p> 500 + </div> 501 + </div> 502 + </div> 503 + | None -> 504 + <p className="mb-4 text-sm text-mist-80"> 505 + (string 506 + "Make sure to log into your account on your old PDS to \ 507 + deactivate it there." ) 508 + </p> ) 509 + ( if blobs_failed > 0 then 510 + <div 511 + className="mb-4 p-3 bg-phoenix-100/10 border border-phoenix-100/30 \ 512 + rounded-lg"> 513 + <p className="text-sm text-phoenix-100"> 514 + (string 515 + (Printf.sprintf 516 + "%d blob(s) failed to import. This means some of your \ 517 + content may appear to have missing media." 518 + blobs_failed ) ) 519 + </p> 520 + </div> 521 + else null ) 522 + <div className="flex gap-3"> 523 + <a href="/account"><Button>(string "go to account")</Button></a> 524 + </div> 525 + </div> 526 + end 527 + 528 + module ErrorView = struct 529 + let[@react.component] make () = 530 + <div> 531 + <p className="text-mist-100 mb-2"> 532 + (string 533 + "Something went wrong during migration. Don't worry, your data on \ 534 + your original PDS is safe." ) 535 + </p> 536 + <p className="text-mist-100 mb-4"> 537 + (string 538 + "Check your internet connection and try again, or contact the PDS \ 539 + administrator if the problem persists.\n\n" ) 540 + </p> 541 + <div className="flex gap-3"> 542 + <a href="/account/migrate"><Button>(string "try again")</Button></a> 543 + <a href="/account/login"> 544 + <Button kind=`Secondary>(string "back to login")</Button> 545 + </a> 546 + </div> 547 + </div> 548 + end 549 + 550 + type props = 551 + { csrf_token: string 552 + ; invite_required: bool 553 + ; hostname: string 554 + ; step: string [@default "enter_credentials"] 555 + ; did: string option [@default None] 556 + ; handle: string option [@default None] 557 + ; old_pds: string option [@default None] 558 + ; identifier: string option [@default None] 559 + ; invite_code: string option [@default None] 560 + ; blobs_imported: int [@default 0] 561 + ; blobs_failed: int [@default 0] 562 + ; old_account_deactivated: bool [@default false] 563 + ; old_account_deactivation_error: string option [@default None] 564 + ; error: string option [@default None] 565 + ; message: string option [@default None] } 566 + [@@deriving json] 567 + 568 + let[@react.component] make 569 + ~props: 570 + ({ csrf_token 571 + ; invite_required 572 + ; hostname= _ 573 + ; step 574 + ; did 575 + ; handle 576 + ; old_pds 577 + ; identifier 578 + ; invite_code 579 + ; blobs_imported 580 + ; blobs_failed 581 + ; old_account_deactivated 582 + ; old_account_deactivation_error 583 + ; error 584 + ; message } : 585 + props ) () = 586 + let current_step = step_of_string step in 587 + let step_number = step_to_number current_step in 588 + let show_progress = 589 + current_step <> EnterCredentials 590 + && current_step <> ResumeAvailable 591 + && current_step <> Enter2FA && current_step <> Error 592 + && current_step <> Complete 593 + in 594 + <main className="w-full h-auto max-w-md px-4 sm:px-0 my-auto"> 595 + <h1 className="text-2xl font-serif text-mana-200 mb-2"> 596 + (string "migrate account") 597 + </h1> 598 + ( if show_progress then 599 + <ProgressIndicator step_number label=(step_to_label current_step) /> 600 + else null ) 601 + ( match error with 602 + | Some err -> 603 + <AlertMessage message=err kind=`Error /> 604 + | None -> 605 + null ) 606 + ( match message with 607 + | Some msg when current_step <> Complete -> 608 + <AlertMessage message=msg kind=`Success /> 609 + | _ -> 610 + null ) 611 + ( match current_step with 612 + | EnterCredentials -> 613 + <CredentialsForm csrf_token invite_required /> 614 + | ResumeAvailable -> 615 + <ResumeForm csrf_token did /> 616 + | Enter2FA -> 617 + <TwoFactorForm 618 + csrf_token 619 + identifier=(Option.value ~default:"" identifier) 620 + old_pds=(Option.value ~default:"" old_pds) 621 + invite_code 622 + /> 623 + | ImportingData -> 624 + <BlobProgress csrf_token did blobs_imported blobs_failed /> 625 + | EnterPlcToken -> 626 + <PlcTokenForm csrf_token did /> 627 + | Complete -> 628 + <CompleteView 629 + handle 630 + blobs_failed 631 + old_account_deactivated 632 + old_account_deactivation_error 633 + /> 634 + | Error -> 635 + <ErrorView /> ) 636 + </main>
+6
frontend/src/templates/SignupPage.mlx
··· 46 46 className="text-mana-100 underline hover:text-mana-200"> 47 47 (string "sign in") 48 48 </a> 49 + (string " or ") 50 + <a 51 + href="/account/migrate" 52 + className="text-mana-100 underline hover:text-mana-200"> 53 + (string "migrate from another PDS") 54 + </a> 49 55 (string ".") 50 56 </span> 51 57 </main>
+2053
pegasus/lib/api/account_/migrate.ml
··· 1 + type create_session_response = Server.CreateSession.response 2 + [@@deriving yojson {strict= false}] 3 + 4 + type get_session_response = Server.GetSession.response 5 + [@@deriving yojson {strict= false}] 6 + 7 + type refresh_session_response = Server.RefreshSession.response 8 + [@@deriving yojson {strict= false}] 9 + 10 + type service_auth_response = Server.GetServiceAuth.response 11 + [@@deriving yojson {strict= false}] 12 + 13 + type list_blobs_response = Repo.ListMissingBlobs.response 14 + [@@deriving yojson {strict= false}] 15 + 16 + type get_preferences_response = Proxy.AppBskyActorGetPreferences.response 17 + [@@deriving yojson {strict= false}] 18 + 19 + type sign_plc_operation_response = Identity.SignPlcOperation.response 20 + [@@deriving yojson {strict= false}] 21 + 22 + type check_account_status_response = Server.CheckAccountStatus.response 23 + [@@deriving yojson {strict= false}] 24 + 25 + type remote_credentials_response = 26 + Identity.GetRecommendedDidCredentials.response 27 + [@@deriving yojson {strict= false}] 28 + 29 + let get_account_status = Server.CheckAccountStatus.get_account_status 30 + 31 + let get_recommended_did_credentials = 32 + Identity.GetRecommendedDidCredentials.get_credentials 33 + 34 + open Cohttp_lwt 35 + 36 + type migration_state = 37 + { did: string 38 + ; handle: string 39 + ; old_pds: string 40 + ; access_jwt: string 41 + ; refresh_jwt: string 42 + ; email: string 43 + ; blobs_imported: int 44 + ; blobs_failed: int 45 + ; blobs_cursor: string 46 + ; plc_requested: bool } 47 + [@@deriving yojson] 48 + 49 + let state_key = "pegasus.migration_state" 50 + 51 + let get_migration_state req = 52 + match Dream.session_field req state_key with 53 + | Some json -> ( 54 + match migration_state_of_yojson (Yojson.Safe.from_string json) with 55 + | Ok state -> 56 + Some state 57 + | Error _ -> 58 + None ) 59 + | None -> 60 + None 61 + 62 + let set_migration_state req state = 63 + Dream.set_session_field req state_key 64 + (Yojson.Safe.to_string (migration_state_to_yojson state)) 65 + 66 + let clear_migration_state req = Dream.drop_session_field req state_key 67 + 68 + let post_json ~uri ~headers ~body = 69 + let headers = Http.Header.add headers "Content-Type" "application/json" in 70 + Cohttp_lwt_unix.Client.post ~headers 71 + ~body:(Body.of_string (Yojson.Safe.to_string body)) 72 + uri 73 + 74 + let post_empty ~uri ~headers = 75 + Cohttp_lwt_unix.Client.post ~headers ~body:Body.empty uri 76 + 77 + let resolve_identity identifier = 78 + let%lwt did = 79 + if String.starts_with ~prefix:"did:" identifier then 80 + Lwt.return_ok identifier 81 + else 82 + match%lwt Id_resolver.Handle.resolve identifier with 83 + | Ok did -> 84 + Lwt.return_ok did 85 + | Error e -> 86 + Lwt.return_error ("Failed to resolve handle: " ^ e) 87 + in 88 + match did with 89 + | Error e -> 90 + Lwt.return_error e 91 + | Ok did -> ( 92 + match%lwt Id_resolver.Did.resolve did with 93 + | Error e -> 94 + Lwt.return_error ("Failed to resolve DID document: " ^ e) 95 + | Ok doc -> ( 96 + match Id_resolver.Did.Document.get_service doc "#atproto_pds" with 97 + | None -> 98 + Lwt.return_error "No PDS service found in DID document" 99 + | Some pds_endpoint -> 100 + (* Get handle from alsoKnownAs *) 101 + let handle = 102 + match doc.also_known_as with 103 + | Some akas -> 104 + List.find_map 105 + (fun aka -> 106 + if String.starts_with ~prefix:"at://" aka then 107 + Some (String.sub aka 5 (String.length aka - 5)) 108 + else None ) 109 + akas 110 + |> Option.value ~default:did 111 + | None -> 112 + did 113 + in 114 + Lwt.return_ok (did, handle, pds_endpoint) ) ) 115 + 116 + type auth_result = 117 + | AuthSuccess of create_session_response 118 + | AuthNeeds2FA 119 + | AuthError of string 120 + 121 + let create_session_on_pds ~pds_endpoint ~identifier ~password ?auth_factor_token 122 + () = 123 + let uri = 124 + Uri.with_path 125 + (Uri.of_string pds_endpoint) 126 + "/xrpc/com.atproto.server.createSession" 127 + in 128 + let body = 129 + let base = 130 + [("identifier", `String identifier); ("password", `String password)] 131 + in 132 + match auth_factor_token with 133 + | Some token -> 134 + `Assoc (("authFactorToken", `String token) :: base) 135 + | None -> 136 + `Assoc base 137 + in 138 + let headers = Http.Header.init () in 139 + try%lwt 140 + let%lwt res, body = post_json ~uri ~headers ~body in 141 + match res.status with 142 + | `OK -> ( 143 + let%lwt body_str = Body.to_string body in 144 + match 145 + create_session_response_of_yojson (Yojson.Safe.from_string body_str) 146 + with 147 + | Ok session -> 148 + Lwt.return (AuthSuccess session) 149 + | Error e -> 150 + Lwt.return (AuthError ("Invalid session response: " ^ e)) ) 151 + | `Unauthorized -> ( 152 + let%lwt body_str = Body.to_string body in 153 + (* Check if 2FA is required *) 154 + try 155 + let json = Yojson.Safe.from_string body_str in 156 + let open Yojson.Safe.Util in 157 + let error = json |> member "error" |> to_string_option in 158 + match error with 159 + | Some "AuthFactorTokenRequired" -> 160 + Lwt.return AuthNeeds2FA 161 + | _ -> 162 + Lwt.return (AuthError "Invalid credentials") 163 + with _ -> Lwt.return (AuthError "Invalid credentials") ) 164 + | status -> 165 + let%lwt body_str = Body.to_string body in 166 + Lwt.return 167 + (AuthError 168 + (Printf.sprintf "Authentication failed (%s): %s" 169 + (Http.Status.to_string status) 170 + body_str ) ) 171 + with exn -> 172 + Lwt.return (AuthError ("Network error: " ^ Printexc.to_string exn)) 173 + 174 + let get_session ~pds_endpoint ~access_jwt = 175 + let uri = 176 + Uri.with_path 177 + (Uri.of_string pds_endpoint) 178 + "/xrpc/com.atproto.server.getSession" 179 + in 180 + let headers = 181 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 182 + in 183 + try%lwt 184 + let%lwt res, body = Util.http_get uri ~headers in 185 + match res.status with 186 + | `OK -> ( 187 + let%lwt body_str = Body.to_string body in 188 + match 189 + get_session_response_of_yojson (Yojson.Safe.from_string body_str) 190 + with 191 + | Ok session -> 192 + Lwt.return_ok session 193 + | Error e -> 194 + Lwt.return_error ("Invalid session response: " ^ e) ) 195 + | status -> 196 + let%lwt body_str = Body.to_string body in 197 + Lwt.return_error 198 + (Printf.sprintf "Failed to get session info (%s): %s" 199 + (Http.Status.to_string status) 200 + body_str ) 201 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 202 + 203 + (* check if jwt is expired or will expire within delta_s *) 204 + let jwt_needs_refresh ?(delta_s = 60) access_jwt = 205 + match Jwt.decode_jwt access_jwt with 206 + | Error _ -> 207 + true (* if we can't decode, assume we need refresh *) 208 + | Ok (_header, payload) -> ( 209 + try 210 + let open Yojson.Safe.Util in 211 + let exp = payload |> member "exp" |> to_int in 212 + let now = int_of_float (Unix.gettimeofday ()) in 213 + exp - now < delta_s 214 + with _ -> true ) 215 + 216 + let refresh_session ~pds_endpoint ~refresh_jwt = 217 + let uri = 218 + Uri.with_path 219 + (Uri.of_string pds_endpoint) 220 + "/xrpc/com.atproto.server.refreshSession" 221 + in 222 + let headers = 223 + Http.Header.of_list [("Authorization", "Bearer " ^ refresh_jwt)] 224 + in 225 + try%lwt 226 + let%lwt res, body = post_empty ~uri ~headers in 227 + match res.status with 228 + | `OK -> ( 229 + let%lwt body_str = Body.to_string body in 230 + match 231 + refresh_session_response_of_yojson (Yojson.Safe.from_string body_str) 232 + with 233 + | Ok tokens -> 234 + Lwt.return_ok tokens 235 + | Error e -> 236 + Lwt.return_error ("Invalid refresh response: " ^ e) ) 237 + | status -> 238 + let%lwt body_str = Body.to_string body in 239 + Lwt.return_error 240 + (Printf.sprintf "Failed to refresh session (%s): %s" 241 + (Http.Status.to_string status) 242 + body_str ) 243 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 244 + 245 + let get_service_auth ~pds_endpoint ~access_jwt = 246 + let uri = 247 + Uri.with_path 248 + (Uri.of_string pds_endpoint) 249 + "/xrpc/com.atproto.server.getServiceAuth" 250 + |> fun u -> 251 + Uri.add_query_params' u 252 + [ ("aud", Env.did) 253 + ; ("lxm", "com.atproto.server.createAccount") 254 + ; ("exp", string_of_int (int_of_float (Unix.gettimeofday ()) + 300)) ] 255 + in 256 + let headers = 257 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 258 + in 259 + try%lwt 260 + let%lwt res, body = Util.http_get uri ~headers in 261 + match res.status with 262 + | `OK -> ( 263 + let%lwt body_str = Body.to_string body in 264 + match 265 + service_auth_response_of_yojson (Yojson.Safe.from_string body_str) 266 + with 267 + | Ok {token} -> 268 + Lwt.return_ok token 269 + | Error e -> 270 + Lwt.return_error ("Invalid service auth response: " ^ e) ) 271 + | status -> 272 + let%lwt body_str = Body.to_string body in 273 + Lwt.return_error 274 + (Printf.sprintf "Failed to get service auth (%s): %s" 275 + (Http.Status.to_string status) 276 + body_str ) 277 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 278 + 279 + (* get credentials from old pds so we can remove the pds rotation key *) 280 + let get_remote_recommended_credentials ~pds_endpoint ~access_jwt = 281 + let uri = 282 + Uri.with_path 283 + (Uri.of_string pds_endpoint) 284 + "/xrpc/com.atproto.identity.getRecommendedDidCredentials" 285 + in 286 + let headers = 287 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 288 + in 289 + try%lwt 290 + let%lwt res, body = Cohttp_lwt_unix.Client.get ~headers uri in 291 + match res.status with 292 + | `OK -> ( 293 + let%lwt body_str = Body.to_string body in 294 + match 295 + remote_credentials_response_of_yojson 296 + (Yojson.Safe.from_string body_str) 297 + with 298 + | Ok creds -> 299 + Lwt.return_ok creds 300 + | Error e -> 301 + Lwt.return_error ("Invalid credentials response: " ^ e) ) 302 + | status -> 303 + let%lwt body_str = Body.to_string body in 304 + Lwt.return_error 305 + (Printf.sprintf "Failed to get remote credentials (%s): %s" 306 + (Http.Status.to_string status) 307 + body_str ) 308 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 309 + 310 + (* list current rotation keys from plc directory audit log *) 311 + let get_plc_rotation_keys ~did = 312 + if not (String.starts_with ~prefix:"did:plc:" did) then Lwt.return_ok [] 313 + else 314 + let uri = 315 + Uri.make ~scheme:"https" ~host:"plc.directory" ~path:(did ^ "/log/last") 316 + () 317 + in 318 + try%lwt 319 + let%lwt res, body = 320 + Cohttp_lwt_unix.Client.get 321 + ~headers:(Http.Header.of_list [("Accept", "application/json")]) 322 + uri 323 + in 324 + match res.status with 325 + | `OK -> ( 326 + let%lwt body_str = Body.to_string body in 327 + try 328 + let json = Yojson.Safe.from_string body_str in 329 + let open Yojson.Safe.Util in 330 + let rotation_keys = 331 + json |> member "rotationKeys" |> to_list |> List.map to_string 332 + in 333 + Lwt.return_ok rotation_keys 334 + with _ -> Lwt.return_ok [] ) 335 + | _ -> 336 + let%lwt () = Body.drain_body body in 337 + Lwt.return_ok [] 338 + with _ -> Lwt.return_ok [] 339 + 340 + let request_plc_operation_signature ~pds_endpoint ~access_jwt = 341 + let uri = 342 + Uri.with_path 343 + (Uri.of_string pds_endpoint) 344 + "/xrpc/com.atproto.identity.requestPlcOperationSignature" 345 + in 346 + let headers = 347 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 348 + in 349 + try%lwt 350 + let%lwt res, body = post_empty ~uri ~headers in 351 + match res.status with 352 + | `OK -> 353 + let%lwt () = Body.drain_body body in 354 + Lwt.return_ok () 355 + | status -> 356 + let%lwt body_str = Body.to_string body in 357 + Lwt.return_error 358 + (Printf.sprintf "Failed to request PLC signature (%s): %s" 359 + (Http.Status.to_string status) 360 + body_str ) 361 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 362 + 363 + let sign_plc_operation ~pds_endpoint ~access_jwt ~token 364 + ~(credentials : Plc.credentials) = 365 + let uri = 366 + Uri.with_path 367 + (Uri.of_string pds_endpoint) 368 + "/xrpc/com.atproto.identity.signPlcOperation" 369 + in 370 + let headers = 371 + Http.Header.of_list 372 + [ ("Authorization", "Bearer " ^ access_jwt) 373 + ; ("Content-Type", "application/json") ] 374 + in 375 + let body = 376 + `Assoc 377 + [ ("token", `String token) 378 + ; ( "rotationKeys" 379 + , `List (List.map (fun s -> `String s) credentials.rotation_keys) ) 380 + ; ( "verificationMethods" 381 + , `Assoc 382 + (List.map 383 + (fun (k, v) -> (k, `String v)) 384 + credentials.verification_methods ) ) 385 + ; ( "alsoKnownAs" 386 + , `List (List.map (fun s -> `String s) credentials.also_known_as) ) 387 + ; ("services", Plc.service_map_to_yojson credentials.services) ] 388 + in 389 + try%lwt 390 + let%lwt res, body = 391 + Cohttp_lwt_unix.Client.post ~headers 392 + ~body:(Body.of_string (Yojson.Safe.to_string body)) 393 + uri 394 + in 395 + match res.status with 396 + | `OK -> ( 397 + let%lwt body_str = Body.to_string body in 398 + match 399 + sign_plc_operation_response_of_yojson 400 + (Yojson.Safe.from_string body_str) 401 + with 402 + | Ok resp -> 403 + Lwt.return_ok resp.operation 404 + | Error e -> 405 + Lwt.return_error ("Invalid sign operation response: " ^ e) ) 406 + | status -> 407 + let%lwt body_str = Body.to_string body in 408 + Lwt.return_error 409 + (Printf.sprintf "Failed to sign PLC operation (%s): %s" 410 + (Http.Status.to_string status) 411 + body_str ) 412 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 413 + 414 + let submit_plc_operation ~did ~handle ~(operation : Plc.signed_operation) db = 415 + match operation with 416 + | Tombstone _ -> 417 + Lwt.return_error "Cannot submit tombstone operation during migration" 418 + | Operation op -> ( 419 + match Plc.validate_operation ~handle (Operation op) with 420 + | Ok () -> ( 421 + match%lwt Plc.submit_operation did operation with 422 + | Ok () -> 423 + let%lwt _ = Sequencer.sequence_identity db ~did () in 424 + let%lwt _ = Id_resolver.Did.resolve ~skip_cache:true did in 425 + Lwt.return_ok () 426 + | Error (status, msg) -> 427 + Lwt.return_error 428 + (Printf.sprintf "PLC submission failed (%d): %s" status msg) ) 429 + | Error e -> 430 + Lwt.return_error e ) 431 + 432 + let fetch_repo ~pds_endpoint ~access_jwt ~did = 433 + let uri = 434 + Uri.with_path (Uri.of_string pds_endpoint) "/xrpc/com.atproto.sync.getRepo" 435 + |> fun u -> Uri.add_query_param' u ("did", did) 436 + in 437 + let headers = 438 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 439 + in 440 + try%lwt 441 + let%lwt res, body = Util.http_get uri ~headers in 442 + match res.status with 443 + | `OK -> 444 + let%lwt body_bytes = Body.to_string body in 445 + Lwt.return_ok (Bytes.of_string body_bytes) 446 + | status -> 447 + let%lwt () = Body.drain_body body in 448 + Lwt.return_error 449 + (Printf.sprintf "Failed to fetch repo (%s)" 450 + (Http.Status.to_string status) ) 451 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 452 + 453 + let list_blobs ~pds_endpoint ~access_jwt ~did ?cursor () = 454 + let uri = 455 + Uri.with_path 456 + (Uri.of_string pds_endpoint) 457 + "/xrpc/com.atproto.sync.listBlobs" 458 + |> fun u -> 459 + Uri.add_query_param' u ("did", did) 460 + |> fun u -> 461 + match cursor with 462 + | Some c -> 463 + Uri.add_query_param' u ("cursor", c) 464 + | None -> 465 + u 466 + in 467 + let headers = 468 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 469 + in 470 + try%lwt 471 + let%lwt res, body = Util.http_get uri ~headers in 472 + match res.status with 473 + | `OK -> ( 474 + let%lwt body_str = Body.to_string body in 475 + match 476 + list_blobs_response_of_yojson (Yojson.Safe.from_string body_str) 477 + with 478 + | Ok resp -> 479 + Lwt.return_ok resp 480 + | Error e -> 481 + Lwt.return_error ("Invalid list blobs response: " ^ e) ) 482 + | status -> 483 + let%lwt () = Body.drain_body body in 484 + Lwt.return_error 485 + (Printf.sprintf "Failed to list blobs (%s)" 486 + (Http.Status.to_string status) ) 487 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 488 + 489 + let fetch_blob ~pds_endpoint ~access_jwt ~did ~cid = 490 + let uri = 491 + Uri.with_path (Uri.of_string pds_endpoint) "/xrpc/com.atproto.sync.getBlob" 492 + |> fun u -> Uri.add_query_params' u [("did", did); ("cid", cid)] 493 + in 494 + let headers = 495 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 496 + in 497 + try%lwt 498 + let%lwt res, body = Util.http_get uri ~headers in 499 + match res.status with 500 + | `OK -> 501 + let content_type = 502 + Http.Header.get res.headers "Content-Type" 503 + |> Option.value ~default:"application/octet-stream" 504 + in 505 + let%lwt body_bytes = Body.to_string body in 506 + Lwt.return_ok (content_type, Bytes.of_string body_bytes) 507 + | status -> 508 + let%lwt () = Body.drain_body body in 509 + Lwt.return_error 510 + (Printf.sprintf "Failed to fetch blob %s (%s)" cid 511 + (Http.Status.to_string status) ) 512 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 513 + 514 + let fetch_preferences ~pds_endpoint ~access_jwt = 515 + let uri = 516 + Uri.with_path 517 + (Uri.of_string pds_endpoint) 518 + "/xrpc/app.bsky.actor.getPreferences" 519 + in 520 + let headers = 521 + Http.Header.of_list [("Authorization", "Bearer " ^ access_jwt)] 522 + in 523 + try%lwt 524 + let%lwt res, body = Util.http_get uri ~headers in 525 + match res.status with 526 + | `OK -> ( 527 + let%lwt body_str = Body.to_string body in 528 + match 529 + get_preferences_response_of_yojson (Yojson.Safe.from_string body_str) 530 + with 531 + | Ok resp -> 532 + Lwt.return_ok resp.preferences 533 + | Error e -> 534 + Dream.warning (fun log -> 535 + log "migration: failed to parse preferences response: %s" e ) ; 536 + Lwt.return_ok (`List []) ) 537 + | status -> 538 + let%lwt () = Body.drain_body body in 539 + Dream.warning (fun log -> 540 + log "migration: failed to fetch preferences: %s" 541 + (Http.Status.to_string status) ) ; 542 + Lwt.return_ok (`List []) 543 + with exn -> 544 + Dream.warning (fun log -> 545 + log "migration: exception fetching preferences: %s" 546 + (Printexc.to_string exn) ) ; 547 + Lwt.return_ok (`List []) 548 + 549 + (* create account on this pds with existing did *) 550 + let create_migrated_account ~email ~handle ~password ~did ~service_auth_token 551 + ?invite_code db = 552 + let open Lwt.Infix in 553 + (* ensure service auth token is signed by the did we're migrating *) 554 + let%lwt verified = 555 + match%lwt 556 + Jwt.verify_service_jwt ~nsid:"com.atproto.server.createAccount" 557 + ~verify_sig:(fun _did pk -> 558 + Lwt.return 559 + @@ Jwt.verify_jwt service_auth_token 560 + ~pubkey:(Kleidos.parse_multikey_str pk) ) 561 + service_auth_token 562 + with 563 + | Ok creds -> 564 + Lwt.return_ok creds 565 + | Error (AuthRequired e) -> 566 + Lwt.return_error @@ Errors.auth_required e 567 + | Error (ExpiredToken e) -> 568 + Lwt.return_error @@ Errors.invalid_request ~name:"ExpiredToken" e 569 + | Error (InvalidToken e) -> 570 + Lwt.return_error @@ Errors.invalid_request ~name:"InvalidToken" e 571 + | Error (InternalError e) -> 572 + Lwt.return_error @@ Errors.internal_error ~msg:e () 573 + in 574 + match verified with 575 + | Error e -> 576 + Lwt.return_error e 577 + | Ok _ -> ( 578 + (* check if did already exists *) 579 + match%lwt 580 + Data_store.get_actor_by_identifier did db 581 + with 582 + | Some existing when existing.deactivated_at <> None -> 583 + (* account exists but is deactivated, resumable migration *) 584 + Lwt.return_error 585 + "RESUMABLE: An incomplete migration exists for this account." 586 + | Some _ -> 587 + Lwt.return_error 588 + "An account with this DID already exists on this PDS. If you \ 589 + previously migrated here, try logging in instead." 590 + | None -> ( 591 + (* check if handle is available (may need different handle) *) 592 + match%lwt 593 + Data_store.get_actor_by_identifier handle db 594 + with 595 + | Some _ -> 596 + Lwt.return_error 597 + ( "The handle @" ^ handle 598 + ^ " is already taken on this PDS. You may need to use a different \ 599 + handle." ) 600 + | None -> ( 601 + (* check if email is available *) 602 + match%lwt 603 + Data_store.get_actor_by_identifier email db 604 + with 605 + | Some _ -> 606 + Lwt.return_error "An account with this email already exists" 607 + | None -> ( 608 + (* validate invite code if required *) 609 + ( match 610 + ( Env.invite_required 611 + , invite_code 612 + , Option.bind invite_code (fun c -> 613 + if String.length c = 0 then None else Some c ) ) 614 + with 615 + | true, None, _ | true, _, None -> 616 + Lwt.return_error "An invite code is required" 617 + | true, Some code, _ -> ( 618 + match%lwt Data_store.get_invite ~code db with 619 + | Some i when i.remaining > 0 -> ( 620 + match%lwt Data_store.use_invite ~code db with 621 + | Some _ -> 622 + Lwt.return_ok () 623 + | None -> 624 + Lwt.return_error "Failed to use invite code" ) 625 + | _ -> 626 + Lwt.return_error "Invalid invite code" ) 627 + | false, _, _ -> 628 + Lwt.return_ok () ) 629 + >>= function 630 + | Error e -> 631 + Lwt.return_error e 632 + | Ok () -> 633 + (* generate new signing key *) 634 + let signing_key, signing_pubkey = 635 + Kleidos.K256.generate_keypair () 636 + in 637 + let sk_priv_mk = Kleidos.K256.privkey_to_multikey signing_key in 638 + (* create deactivated actor *) 639 + let%lwt () = 640 + Data_store.create_actor ~did ~handle ~email ~password 641 + ~signing_key:sk_priv_mk db 642 + in 643 + let%lwt () = Data_store.deactivate_actor did db in 644 + let () = 645 + Util.mkfile_p 646 + (Util.Constants.user_db_filepath did) 647 + ~perm:0o644 648 + in 649 + let%lwt _ = Sequencer.sequence_identity db ~did ~handle () in 650 + let%lwt _ = 651 + Sequencer.sequence_account db ~did ~active:false 652 + ~status:`Deactivated () 653 + in 654 + Lwt.return_ok (Kleidos.K256.pubkey_to_did_key signing_pubkey) ) 655 + ) ) ) 656 + 657 + let bytes_to_car_stream (data : bytes) : Car.stream = 658 + fun () -> Lwt.return (Lwt_seq.Cons (data, fun () -> Lwt.return Lwt_seq.Nil)) 659 + 660 + let import_repo ~did ~car_data = 661 + try%lwt 662 + let%lwt repo = Repository.load ~create:true did in 663 + let stream = bytes_to_car_stream car_data in 664 + match%lwt Repository.import_car repo stream with 665 + | Ok _ -> 666 + Lwt.return_ok () 667 + | Error e -> 668 + Lwt.return_error ("Failed to import repository: " ^ Printexc.to_string e) 669 + with exn -> 670 + Lwt.return_error ("Failed to import repository: " ^ Printexc.to_string exn) 671 + 672 + (* import blobs in batches *) 673 + let import_blobs_batch ~pds_endpoint ~access_jwt ~did ~cids = 674 + let%lwt user_db = User_store.connect ~create:true did in 675 + let%lwt results = 676 + Lwt_list.map_p 677 + (fun cid_str -> 678 + match%lwt fetch_blob ~pds_endpoint ~access_jwt ~did ~cid:cid_str with 679 + | Error e -> 680 + Dream.warning (fun log -> 681 + log "migration %s: failed to fetch blob %s: %s" did cid_str e ) ; 682 + Lwt.return_error cid_str 683 + | Ok (mimetype, data) -> ( 684 + match Cid.of_string cid_str with 685 + | Error _ -> 686 + Lwt.return_error cid_str 687 + | Ok cid -> 688 + let%lwt _ = User_store.put_blob user_db cid mimetype data in 689 + Lwt.return_ok cid_str ) ) 690 + cids 691 + in 692 + let imported = 693 + List.filter (function Ok _ -> true | Error _ -> false) results 694 + |> List.length 695 + in 696 + let failed = 697 + List.filter (function Error _ -> true | Ok _ -> false) results 698 + |> List.length 699 + in 700 + Lwt.return (imported, failed) 701 + 702 + (* remove trailing slash from pds endpoint *) 703 + let normalize_endpoint s = 704 + if String.length s > 0 && s.[String.length s - 1] = '/' then 705 + String.sub s 0 (String.length s - 1) 706 + else s 707 + 708 + (* check if plc identity has been updated to point to this pds *) 709 + let check_identity_updated did = 710 + match%lwt Id_resolver.Did.resolve ~skip_cache:true did with 711 + | Error e -> 712 + Lwt.return_error ("Failed to resolve DID: " ^ e) 713 + | Ok doc -> ( 714 + match Id_resolver.Did.Document.get_service doc "#atproto_pds" with 715 + | None -> 716 + Lwt.return_error "DID document missing PDS service" 717 + | Some endpoint -> 718 + let normalized_endpoint = normalize_endpoint endpoint in 719 + let normalized_host = normalize_endpoint Env.host_endpoint in 720 + if normalized_endpoint = normalized_host then Lwt.return_ok true 721 + else Lwt.return_ok false ) 722 + 723 + let check_local_account_status ~did = 724 + try%lwt 725 + match%lwt get_account_status did with 726 + | Ok status -> 727 + Lwt.return_ok status 728 + | _ -> 729 + Lwt.return_error "Failed to load account data" 730 + with exn -> 731 + Lwt.return_error 732 + ("Failed to check account status: " ^ Printexc.to_string exn) 733 + 734 + let list_local_missing_blobs ~did ~limit ?cursor () = 735 + try%lwt 736 + let%lwt {db= us; _} = Repository.load did in 737 + let cursor = Option.value ~default:"" cursor in 738 + let%lwt blobs = User_store.list_missing_blobs ~limit ~cursor us in 739 + let cids = List.map (fun (_, cid) -> Cid.to_string cid) blobs in 740 + (* done if we get fewer blobs than limit *) 741 + let next_cursor = 742 + if List.length blobs >= limit then List.nth_opt cids (List.length cids - 1) 743 + else None 744 + in 745 + Lwt.return_ok (cids, next_cursor) 746 + with exn -> 747 + Lwt.return_error ("Failed to list missing blobs: " ^ Printexc.to_string exn) 748 + 749 + let activate_account did db = 750 + let%lwt () = Data_store.activate_actor did db in 751 + let%lwt _ = 752 + Sequencer.sequence_account db ~did ~active:true ~status:`Active () 753 + in 754 + Lwt.return_unit 755 + 756 + (* deactivate account on old pds after successful migration *) 757 + let deactivate_old_account ~pds_endpoint ~access_jwt = 758 + let uri = 759 + Uri.with_path 760 + (Uri.of_string pds_endpoint) 761 + "/xrpc/com.atproto.server.deactivateAccount" 762 + in 763 + let headers = 764 + Http.Header.of_list 765 + [ ("Authorization", "Bearer " ^ access_jwt) 766 + ; ("Content-Type", "application/json") ] 767 + in 768 + try%lwt 769 + let%lwt res, body = 770 + Cohttp_lwt_unix.Client.post ~headers ~body:(Body.of_string "{}") uri 771 + in 772 + match res.status with 773 + | `OK -> 774 + let%lwt () = Body.drain_body body in 775 + Lwt.return_ok () 776 + | status -> 777 + let%lwt body_str = Body.to_string body in 778 + Lwt.return_error 779 + (Printf.sprintf "Failed to deactivate old account (%s): %s" 780 + (Http.Status.to_string status) 781 + body_str ) 782 + with exn -> Lwt.return_error ("Network error: " ^ Printexc.to_string exn) 783 + 784 + (* possible states for an existing deactivated account for resumption *) 785 + type resume_state = 786 + | NeedsRepoImport (* account exists but no repo *) 787 + | NeedsBlobImport (* repo exists, may need blobs *) 788 + | NeedsPlcUpdate (* data imported, needs plc update *) 789 + | NeedsActivation (* plc points here, just needs activation *) 790 + | AlreadyActive (* account is already active *) 791 + 792 + let check_resume_state ~did db = 793 + match%lwt Data_store.get_actor_by_identifier did db with 794 + | None -> 795 + Lwt.return_error "Account not found" 796 + | Some actor when actor.deactivated_at = None -> 797 + Lwt.return_ok AlreadyActive 798 + | Some _actor -> ( 799 + (* account is deactivated, check if identity already points here *) 800 + match%lwt 801 + check_identity_updated did 802 + with 803 + | Ok true -> 804 + (* just needs activation *) 805 + Lwt.return_ok NeedsActivation 806 + | _ -> ( 807 + (* check if repo exists; error probably means it doesn't *) 808 + try%lwt 809 + let%lwt us = User_store.connect ~create:false did in 810 + let%lwt record_count = User_store.count_records us in 811 + if record_count > 0 then 812 + (* repo exists, check if we need blob import *) 813 + match%lwt User_store.count_blobs us with 814 + | cnt when cnt > 0 -> 815 + (* data imported, needs plc update *) 816 + Lwt.return_ok NeedsPlcUpdate 817 + | _ -> 818 + (* no blobs, need to start from blob import *) 819 + Lwt.return_ok NeedsBlobImport 820 + else 821 + (* no repo, need to start from repo import *) 822 + Lwt.return_ok NeedsRepoImport 823 + with _ -> Lwt.return_ok NeedsRepoImport ) ) 824 + 825 + let get_handler = 826 + Xrpc.handler (fun ctx -> 827 + let csrf_token = Dream.csrf_token ctx.req in 828 + let invite_required = Env.invite_required in 829 + let hostname = Env.hostname in 830 + (* check for existing migration state *) 831 + let props : Frontend.MigratePage.props = 832 + match get_migration_state ctx.req with 833 + | None -> 834 + { csrf_token 835 + ; invite_required 836 + ; hostname 837 + ; step= "enter_credentials" 838 + ; did= None 839 + ; handle= None 840 + ; old_pds= None 841 + ; identifier= None 842 + ; invite_code= None 843 + ; blobs_imported= 0 844 + ; blobs_failed= 0 845 + ; old_account_deactivated= false 846 + ; old_account_deactivation_error= None 847 + ; error= None 848 + ; message= None } 849 + | Some state -> 850 + if state.plc_requested then 851 + { csrf_token 852 + ; invite_required 853 + ; hostname 854 + ; step= "enter_plc_token" 855 + ; did= Some state.did 856 + ; handle= Some state.handle 857 + ; old_pds= Some state.old_pds 858 + ; identifier= None 859 + ; invite_code= None 860 + ; blobs_imported= state.blobs_imported 861 + ; blobs_failed= state.blobs_failed 862 + ; old_account_deactivated= false 863 + ; old_account_deactivation_error= None 864 + ; error= None 865 + ; message= None } 866 + else 867 + { csrf_token 868 + ; invite_required 869 + ; hostname 870 + ; step= "importing_data" 871 + ; did= Some state.did 872 + ; handle= Some state.handle 873 + ; old_pds= Some state.old_pds 874 + ; identifier= None 875 + ; invite_code= None 876 + ; blobs_imported= state.blobs_imported 877 + ; blobs_failed= state.blobs_failed 878 + ; old_account_deactivated= false 879 + ; old_account_deactivation_error= None 880 + ; error= None 881 + ; message= None } 882 + in 883 + Util.render_html ~title:"Migrate Account" 884 + (module Frontend.MigratePage) 885 + ~props ) 886 + 887 + let post_handler = 888 + Xrpc.handler (fun ctx -> 889 + let csrf_token = Dream.csrf_token ctx.req in 890 + let invite_required = Env.invite_required in 891 + let hostname = Env.hostname in 892 + let make_props ?(step = "enter_credentials") ?did ?handle ?old_pds 893 + ?identifier ?invite_code ?(blobs_imported = 0) ?(blobs_failed = 0) 894 + ?(old_account_deactivated = false) ?old_account_deactivation_error 895 + ?error ?message () : Frontend.MigratePage.props = 896 + { csrf_token 897 + ; invite_required 898 + ; hostname 899 + ; step 900 + ; did 901 + ; handle 902 + ; old_pds 903 + ; identifier 904 + ; invite_code 905 + ; blobs_imported 906 + ; blobs_failed 907 + ; old_account_deactivated 908 + ; old_account_deactivation_error 909 + ; error 910 + ; message } 911 + in 912 + let render_error ?(step = "enter_credentials") ?did ?handle ?old_pds 913 + ?identifier ?invite_code error = 914 + Util.render_html ~status:`Bad_Request ~title:"Migrate Account" 915 + (module Frontend.MigratePage) 916 + ~props: 917 + (make_props ~step ?did ?handle ?old_pds ?identifier ?invite_code 918 + ~error () ) 919 + in 920 + (* helper to transition to PLC token step after data import *) 921 + let transition_to_plc_token_step ~did ~handle ~old_pds ~access_jwt 922 + ~refresh_jwt ~email ~blobs_imported ~blobs_failed = 923 + (* import preferences before transitioning *) 924 + let%lwt () = 925 + match%lwt fetch_preferences ~pds_endpoint:old_pds ~access_jwt with 926 + | Ok prefs -> 927 + Data_store.put_preferences ~did ~prefs ctx.db 928 + | _ -> 929 + Lwt.return_unit 930 + in 931 + (* don't need plc step for did:web *) 932 + if String.starts_with ~prefix:"did:web:" did then 933 + (* check if identity already points here *) 934 + match%lwt check_identity_updated did with 935 + | Ok true -> 936 + (* identity already points here, activate directly *) 937 + let%lwt () = activate_account did ctx.db in 938 + let%lwt () = Session.log_in_did ctx.req did in 939 + let%lwt deactivation_result = 940 + deactivate_old_account ~pds_endpoint:old_pds ~access_jwt 941 + in 942 + let old_account_deactivated, old_account_deactivation_error = 943 + match deactivation_result with 944 + | Ok () -> 945 + (true, None) 946 + | Error e -> 947 + Dream.warning (fun log -> 948 + log "migration %s: failed to deactivate old account: %s" 949 + did e ) ; 950 + (false, Some e) 951 + in 952 + Util.render_html ~title:"Migrate Account" 953 + (module Frontend.MigratePage) 954 + ~props: 955 + (make_props ~step:"complete" ~did ~handle ~blobs_imported 956 + ~blobs_failed ~old_account_deactivated 957 + ?old_account_deactivation_error 958 + ~message: 959 + "Your account has been successfully migrated! Your \ 960 + did:web identity is pointing to this PDS." 961 + () ) 962 + | _ -> 963 + (* identity not updated yet - show instructions *) 964 + Util.render_html ~title:"Migrate Account" 965 + (module Frontend.MigratePage) 966 + ~props: 967 + (make_props ~step:"error" ~did ~handle ~blobs_imported 968 + ~blobs_failed 969 + ~error: 970 + (Printf.sprintf 971 + "Your account uses did:web which requires manual \ 972 + configuration. Please update your \ 973 + .well-known/did.json at %s to point to this PDS \ 974 + (%s), then try resuming the migration." 975 + (String.sub did 8 (String.length did - 8)) 976 + Env.host_endpoint ) 977 + () ) 978 + else 979 + (* did:plc, regular flow *) 980 + match%lwt 981 + request_plc_operation_signature ~pds_endpoint:old_pds ~access_jwt 982 + with 983 + | Error e -> 984 + Dream.warning (fun log -> 985 + log "migration %s: failed to request PLC signature: %s" did e ) ; 986 + (* still show the token step, user may have received email already *) 987 + let%lwt () = 988 + set_migration_state ctx.req 989 + { did 990 + ; handle 991 + ; old_pds 992 + ; access_jwt 993 + ; refresh_jwt 994 + ; email 995 + ; blobs_imported 996 + ; blobs_failed 997 + ; blobs_cursor= "" 998 + ; plc_requested= true } 999 + in 1000 + Util.render_html ~title:"Migrate Account" 1001 + (module Frontend.MigratePage) 1002 + ~props: 1003 + (make_props ~step:"enter_plc_token" ~did ~handle ~old_pds 1004 + ~blobs_imported ~blobs_failed 1005 + ~message: 1006 + "Data import complete! Check your email for a PLC \ 1007 + confirmation code." 1008 + ~error: 1009 + ( "Note: Could not automatically request PLC signature: " 1010 + ^ e 1011 + ^ ". You may need to request it manually from your old \ 1012 + PDS." ) 1013 + () ) 1014 + | Ok () -> 1015 + let%lwt () = 1016 + set_migration_state ctx.req 1017 + { did 1018 + ; handle 1019 + ; old_pds 1020 + ; access_jwt 1021 + ; refresh_jwt 1022 + ; email 1023 + ; blobs_imported 1024 + ; blobs_failed 1025 + ; blobs_cursor= "" 1026 + ; plc_requested= true } 1027 + in 1028 + Util.render_html ~title:"Migrate Account" 1029 + (module Frontend.MigratePage) 1030 + ~props: 1031 + (make_props ~step:"enter_plc_token" ~did ~handle ~old_pds 1032 + ~blobs_imported ~blobs_failed 1033 + ~message: 1034 + "Data import complete! Check your email for a PLC \ 1035 + confirmation code." 1036 + () ) 1037 + in 1038 + match%lwt Dream.form ctx.req with 1039 + | `Ok fields -> ( 1040 + let action = 1041 + List.assoc_opt "action" fields |> Option.value ~default:"" 1042 + in 1043 + match action with 1044 + | "start_migration" -> ( 1045 + let identifier = 1046 + List.assoc_opt "identifier" fields 1047 + |> Option.value ~default:"" |> String.trim 1048 + in 1049 + let password = 1050 + List.assoc_opt "password" fields |> Option.value ~default:"" 1051 + in 1052 + let invite_code = 1053 + List.assoc_opt "invite_code" fields 1054 + |> Option.map String.trim 1055 + |> fun c -> 1056 + Option.bind c (fun s -> 1057 + if String.length s = 0 then None else Some s ) 1058 + in 1059 + (* for jumping to specific steps while debugging *) 1060 + let debug_step = 1061 + match invite_code with 1062 + | Some "DEBUG:RESUME" -> 1063 + Some "resume_available" 1064 + | Some "DEBUG:IMPORT" -> 1065 + Some "importing_data" 1066 + | Some "DEBUG:PLC" -> 1067 + Some "enter_plc_token" 1068 + | Some "DEBUG:COMPLETE" -> 1069 + Some "complete" 1070 + | Some "DEBUG:COMPLETE_FAIL" -> 1071 + Some "complete_deactivation_failed" 1072 + | Some "DEBUG:ERROR" -> 1073 + Some "error" 1074 + | _ -> 1075 + None 1076 + in 1077 + match debug_step with 1078 + | Some "resume_available" -> 1079 + Util.render_html ~title:"Migrate Account" 1080 + (module Frontend.MigratePage) 1081 + ~props: 1082 + (make_props ~step:"resume_available" ~did:"did:plc:a1b2c3" 1083 + ~handle:"test.user" ~old_pds:"https://bsky.social" () ) 1084 + | Some "importing_data" -> 1085 + let%lwt () = 1086 + set_migration_state ctx.req 1087 + { did= "did:plc:a1b2c3" 1088 + ; handle= "test.user" 1089 + ; old_pds= "https://bsky.social" 1090 + ; access_jwt= "test_access_jwt" 1091 + ; refresh_jwt= "test_refresh_jwt" 1092 + ; email= "test@example.com" 1093 + ; blobs_imported= 42 1094 + ; blobs_failed= 3 1095 + ; blobs_cursor= "" 1096 + ; plc_requested= false } 1097 + in 1098 + Util.render_html ~title:"Migrate Account" 1099 + (module Frontend.MigratePage) 1100 + ~props: 1101 + (make_props ~step:"importing_data" ~did:"did:plc:a1b2c3" 1102 + ~handle:"test.user" ~old_pds:"https://bsky.social" 1103 + ~blobs_imported:42 ~blobs_failed:3 () ) 1104 + | Some "enter_plc_token" -> 1105 + let%lwt () = 1106 + set_migration_state ctx.req 1107 + { did= "did:plc:a1b2c3" 1108 + ; handle= "test.user" 1109 + ; old_pds= "https://bsky.social" 1110 + ; access_jwt= "test_access_jwt" 1111 + ; refresh_jwt= "test_refresh_jwt" 1112 + ; email= "test@example.com" 1113 + ; blobs_imported= 100 1114 + ; blobs_failed= 0 1115 + ; blobs_cursor= "" 1116 + ; plc_requested= true } 1117 + in 1118 + Util.render_html ~title:"Migrate Account" 1119 + (module Frontend.MigratePage) 1120 + ~props: 1121 + (make_props ~step:"enter_plc_token" 1122 + ~did:"did:plc:testuser123" ~handle:"test.user" 1123 + ~old_pds:"https://bsky.social" ~blobs_imported:100 1124 + ~blobs_failed:0 1125 + ~message: 1126 + "Data import complete! Check your email for a PLC \ 1127 + confirmation code." 1128 + () ) 1129 + | Some "complete" -> 1130 + Util.render_html ~title:"Migrate Account" 1131 + (module Frontend.MigratePage) 1132 + ~props: 1133 + (make_props ~step:"complete" ~did:"did:plc:testuser123" 1134 + ~handle:"test.user" ~blobs_imported:100 ~blobs_failed:0 1135 + ~old_account_deactivated:true 1136 + ~message:"Your account has been successfully migrated!" 1137 + () ) 1138 + | Some "complete_deactivation_failed" -> 1139 + Util.render_html ~title:"Migrate Account" 1140 + (module Frontend.MigratePage) 1141 + ~props: 1142 + (make_props ~step:"complete" ~did:"did:plc:testuser123" 1143 + ~handle:"test.user" ~blobs_imported:95 ~blobs_failed:5 1144 + ~old_account_deactivated:false 1145 + ~old_account_deactivation_error: 1146 + "Failed to deactivate old account (401): \ 1147 + Unauthorized" 1148 + ~message:"Your account has been successfully migrated!" 1149 + () ) 1150 + | Some "error" -> 1151 + Util.render_html ~title:"Migrate Account" 1152 + (module Frontend.MigratePage) 1153 + ~props:(make_props ~step:"error" ()) 1154 + | _ -> ( 1155 + if 1156 + (* normal flow *) 1157 + String.length identifier = 0 1158 + then render_error "Please enter your handle or DID" 1159 + else if String.length password = 0 then 1160 + render_error "Please enter your password" 1161 + else 1162 + (* step 1: resolve identity *) 1163 + match%lwt resolve_identity identifier with 1164 + | Error e -> 1165 + render_error e 1166 + | Ok (did, handle, old_pds) -> ( 1167 + (* step 2: authenticate with old pds *) 1168 + match%lwt 1169 + create_session_on_pds ~pds_endpoint:old_pds ~identifier 1170 + ~password () 1171 + with 1172 + | AuthError e -> 1173 + render_error e 1174 + | AuthNeeds2FA -> 1175 + (* show 2FA form *) 1176 + Util.render_html ~title:"Migrate Account" 1177 + (module Frontend.MigratePage) 1178 + ~props: 1179 + (make_props ~step:"enter_2fa" ~identifier ~old_pds 1180 + ?invite_code () ) 1181 + | AuthSuccess session -> ( 1182 + (* step 3: get session info for account status and email *) 1183 + match%lwt 1184 + get_session ~pds_endpoint:old_pds 1185 + ~access_jwt:session.access_jwt 1186 + with 1187 + | Error e -> 1188 + render_error ("Failed to get account info: " ^ e) 1189 + | Ok session_info -> ( 1190 + let is_active = 1191 + match session_info.active with 1192 + | Some false -> 1193 + false 1194 + | _ -> 1195 + true (* default to true if not specified *) 1196 + in 1197 + if not is_active then 1198 + render_error 1199 + "This account is already deactivated. Cannot \ 1200 + migrate a deactivated account." 1201 + else 1202 + (* step 4: get service auth token *) 1203 + match%lwt 1204 + get_service_auth ~pds_endpoint:old_pds 1205 + ~access_jwt:session.access_jwt 1206 + with 1207 + | Error e -> 1208 + render_error 1209 + ("Failed to get service authorization: " ^ e) 1210 + | Ok service_auth_token -> ( 1211 + (* use real email from old PDS, fallback to placeholder *) 1212 + let email = 1213 + match session_info.email with 1214 + | Some e when String.length e > 0 -> 1215 + e 1216 + | _ -> 1217 + Printf.sprintf "%s@%s" did Env.hostname 1218 + in 1219 + (* Step 5: Create account *) 1220 + match%lwt 1221 + create_migrated_account ~email ~handle 1222 + ~password ~did ~service_auth_token 1223 + ?invite_code ctx.db 1224 + with 1225 + | Error e 1226 + when String.starts_with ~prefix:"RESUMABLE:" 1227 + e -> ( 1228 + (* try to automatically resume *) 1229 + match%lwt 1230 + check_resume_state ~did ctx.db 1231 + with 1232 + | Error e -> 1233 + render_error ~did ~handle ~old_pds e 1234 + | Ok AlreadyActive -> 1235 + (* account is already active, just log them in *) 1236 + let%lwt () = 1237 + Session.log_in_did ctx.req did 1238 + in 1239 + Util.render_html 1240 + ~title:"Migrate Account" 1241 + (module Frontend.MigratePage) 1242 + ~props: 1243 + (make_props ~step:"complete" ~did 1244 + ~handle 1245 + ~message: 1246 + "Your account is already \ 1247 + active! You have been logged \ 1248 + in." 1249 + () ) 1250 + | Ok NeedsActivation -> 1251 + (* identity already points here, just activate *) 1252 + let%lwt () = 1253 + activate_account did ctx.db 1254 + in 1255 + let%lwt () = 1256 + Session.log_in_did ctx.req did 1257 + in 1258 + let%lwt deactivation_result = 1259 + match%lwt 1260 + deactivate_old_account 1261 + ~pds_endpoint:old_pds 1262 + ~access_jwt:session.access_jwt 1263 + with 1264 + | Ok () -> 1265 + Lwt.return_ok () 1266 + | Error err 1267 + when Util.str_contains ~affix:"401" 1268 + err 1269 + || Util.str_contains 1270 + ~affix:"Unauthorized" err 1271 + -> ( 1272 + match%lwt 1273 + refresh_session 1274 + ~pds_endpoint:old_pds 1275 + ~refresh_jwt:session.refresh_jwt 1276 + with 1277 + | Ok tokens -> 1278 + deactivate_old_account 1279 + ~pds_endpoint:old_pds 1280 + ~access_jwt:tokens.access_jwt 1281 + | Error refresh_err -> 1282 + Lwt.return_error 1283 + (Printf.sprintf 1284 + "Token expired and \ 1285 + refresh failed: %s" 1286 + refresh_err ) ) 1287 + | Error err -> 1288 + Lwt.return_error err 1289 + in 1290 + let ( old_account_deactivated 1291 + , old_account_deactivation_error ) = 1292 + match deactivation_result with 1293 + | Ok () -> 1294 + (true, None) 1295 + | Error err -> 1296 + Dream.warning (fun log -> 1297 + log 1298 + "migration %s: failed to \ 1299 + deactivate old account: \ 1300 + %s" 1301 + did err ) ; 1302 + (false, Some err) 1303 + in 1304 + Util.render_html 1305 + ~title:"Migrate Account" 1306 + (module Frontend.MigratePage) 1307 + ~props: 1308 + (make_props ~step:"complete" ~did 1309 + ~handle ~old_account_deactivated 1310 + ?old_account_deactivation_error 1311 + ~message: 1312 + "Your account has been \ 1313 + activated! Your identity is \ 1314 + pointing to this PDS." 1315 + () ) 1316 + | Ok NeedsPlcUpdate -> 1317 + (* data is imported, need plc update *) 1318 + transition_to_plc_token_step ~did 1319 + ~handle ~old_pds 1320 + ~access_jwt:session.access_jwt 1321 + ~refresh_jwt:session.refresh_jwt 1322 + ~email ~blobs_imported:0 1323 + ~blobs_failed:0 1324 + | Ok NeedsRepoImport | Ok NeedsBlobImport 1325 + -> ( 1326 + (* need to re-import data, continue with normal flow *) 1327 + match%lwt 1328 + fetch_repo ~pds_endpoint:old_pds 1329 + ~access_jwt:session.access_jwt ~did 1330 + with 1331 + | Error err -> 1332 + render_error ~did ~handle ~old_pds 1333 + ( "Failed to fetch repository: " 1334 + ^ err ) 1335 + | Ok car_data -> ( 1336 + match%lwt 1337 + import_repo ~did ~car_data 1338 + with 1339 + | Error err -> 1340 + render_error ~did ~handle ~old_pds 1341 + err 1342 + | Ok () -> 1343 + (* continue with blob import like normal flow *) 1344 + transition_to_plc_token_step ~did 1345 + ~handle ~old_pds 1346 + ~access_jwt:session.access_jwt 1347 + ~refresh_jwt:session.refresh_jwt 1348 + ~email ~blobs_imported:0 1349 + ~blobs_failed:0 ) ) ) 1350 + | Error e -> 1351 + render_error e 1352 + | Ok _signing_key_did -> ( 1353 + (* step 5: fetch and import repo *) 1354 + match%lwt 1355 + fetch_repo ~pds_endpoint:old_pds 1356 + ~access_jwt:session.access_jwt ~did 1357 + with 1358 + | Error e -> 1359 + render_error 1360 + ("Failed to fetch repository: " ^ e) 1361 + | Ok car_data -> ( 1362 + match%lwt import_repo ~did ~car_data with 1363 + | Error e -> 1364 + render_error e 1365 + | Ok () -> ( 1366 + (* log account status after repo import *) 1367 + let%lwt () = 1368 + match%lwt 1369 + check_local_account_status ~did 1370 + with 1371 + | Ok status -> 1372 + Dream.info (fun log -> 1373 + log 1374 + "migration %s: repo \ 1375 + imported, \ 1376 + indexed_records=%d, \ 1377 + expected_blobs=%d" 1378 + did status.indexed_records 1379 + status.expected_blobs ) ; 1380 + Lwt.return_unit 1381 + | Error e -> 1382 + Dream.warning (fun log -> 1383 + log 1384 + "migration %s: failed to \ 1385 + check account status: \ 1386 + %s" 1387 + did e ) ; 1388 + Lwt.return_unit 1389 + in 1390 + (* step 6: list missing blobs to import *) 1391 + match%lwt 1392 + list_local_missing_blobs ~did 1393 + ~limit:50 () 1394 + with 1395 + | Error e -> 1396 + Dream.warning (fun log -> 1397 + log 1398 + "migration %s: failed to \ 1399 + list missing blobs: %s" 1400 + did e ) ; 1401 + (* skip blobs, go to plc token step *) 1402 + transition_to_plc_token_step ~did 1403 + ~handle ~old_pds 1404 + ~access_jwt:session.access_jwt 1405 + ~refresh_jwt:session.refresh_jwt 1406 + ~email ~blobs_imported:0 1407 + ~blobs_failed:0 1408 + | Ok (missing_cids, next_cursor) -> 1409 + if List.length missing_cids = 0 1410 + then 1411 + (* no missing blobs, go to plc token step *) 1412 + transition_to_plc_token_step 1413 + ~did ~handle ~old_pds 1414 + ~access_jwt:session.access_jwt 1415 + ~refresh_jwt: 1416 + session.refresh_jwt ~email 1417 + ~blobs_imported:0 1418 + ~blobs_failed:0 1419 + else 1420 + (* import this batch of missing blobs *) 1421 + let%lwt imported, failed = 1422 + import_blobs_batch 1423 + ~pds_endpoint:old_pds 1424 + ~access_jwt: 1425 + session.access_jwt ~did 1426 + ~cids:missing_cids 1427 + in 1428 + (* store state for continuation *) 1429 + let cursor = 1430 + Option.value ~default:"" 1431 + next_cursor 1432 + in 1433 + (* check if there are more missing blobs *) 1434 + if String.length cursor = 0 then 1435 + (* no more missing blobs, go to plc *) 1436 + transition_to_plc_token_step 1437 + ~did ~handle ~old_pds 1438 + ~access_jwt: 1439 + session.access_jwt 1440 + ~refresh_jwt: 1441 + session.refresh_jwt ~email 1442 + ~blobs_imported:imported 1443 + ~blobs_failed:failed 1444 + else 1445 + (* more blobs to import, save state *) 1446 + let%lwt () = 1447 + set_migration_state ctx.req 1448 + { did 1449 + ; handle 1450 + ; old_pds 1451 + ; access_jwt= 1452 + session.access_jwt 1453 + ; refresh_jwt= 1454 + session.refresh_jwt 1455 + ; email 1456 + ; blobs_imported= imported 1457 + ; blobs_failed= failed 1458 + ; blobs_cursor= cursor 1459 + ; plc_requested= false } 1460 + in 1461 + Util.render_html 1462 + ~title:"Migrate Account" 1463 + (module Frontend.MigratePage) 1464 + ~props: 1465 + (make_props 1466 + ~step:"importing_data" 1467 + ~did ~handle ~old_pds 1468 + ~blobs_imported: 1469 + imported 1470 + ~blobs_failed:failed () ) 1471 + ) ) ) ) ) ) ) ) ) 1472 + | "continue_blobs" -> ( 1473 + match get_migration_state ctx.req with 1474 + | None -> 1475 + render_error "Migration state not found. Please start over." 1476 + | Some state -> ( 1477 + (* refresh token if needed before continuing blob import *) 1478 + let%lwt state = 1479 + if jwt_needs_refresh state.access_jwt then ( 1480 + match%lwt 1481 + refresh_session ~pds_endpoint:state.old_pds 1482 + ~refresh_jwt:state.refresh_jwt 1483 + with 1484 + | Ok tokens -> 1485 + let new_state = 1486 + { state with 1487 + access_jwt= tokens.access_jwt 1488 + ; refresh_jwt= tokens.refresh_jwt } 1489 + in 1490 + let%lwt () = set_migration_state ctx.req new_state in 1491 + Lwt.return new_state 1492 + | Error e -> 1493 + Dream.warning (fun log -> 1494 + log 1495 + "migration %s: token refresh failed, continuing \ 1496 + with old token: %s" 1497 + state.did e ) ; 1498 + Lwt.return state ) 1499 + else Lwt.return state 1500 + in 1501 + (* continue importing missing blobs *) 1502 + let cursor = 1503 + if String.length state.blobs_cursor > 0 then 1504 + Some state.blobs_cursor 1505 + else None 1506 + in 1507 + match%lwt 1508 + list_local_missing_blobs ~did:state.did ~limit:50 ?cursor () 1509 + with 1510 + | Error e -> 1511 + Dream.warning (fun log -> 1512 + log "migration %s: failed to list missing blobs: %s" 1513 + state.did e ) ; 1514 + (* no more blobs, go to plc token step *) 1515 + transition_to_plc_token_step ~did:state.did 1516 + ~handle:state.handle ~old_pds:state.old_pds 1517 + ~access_jwt:state.access_jwt 1518 + ~refresh_jwt:state.refresh_jwt ~email:state.email 1519 + ~blobs_imported:state.blobs_imported 1520 + ~blobs_failed:state.blobs_failed 1521 + | Ok (missing_cids, next_cursor) -> 1522 + if List.length missing_cids = 0 then 1523 + (* no more blobs, go to plc token step *) 1524 + transition_to_plc_token_step ~did:state.did 1525 + ~handle:state.handle ~old_pds:state.old_pds 1526 + ~access_jwt:state.access_jwt 1527 + ~refresh_jwt:state.refresh_jwt ~email:state.email 1528 + ~blobs_imported:state.blobs_imported 1529 + ~blobs_failed:state.blobs_failed 1530 + else 1531 + let%lwt imported, failed = 1532 + import_blobs_batch ~pds_endpoint:state.old_pds 1533 + ~access_jwt:state.access_jwt ~did:state.did 1534 + ~cids:missing_cids 1535 + in 1536 + let new_imported = state.blobs_imported + imported in 1537 + let new_failed = state.blobs_failed + failed in 1538 + let new_cursor = Option.value ~default:"" next_cursor in 1539 + (* check if there are more missing blobs *) 1540 + if String.length new_cursor = 0 then 1541 + (* all done, go to plc *) 1542 + transition_to_plc_token_step ~did:state.did 1543 + ~handle:state.handle ~old_pds:state.old_pds 1544 + ~access_jwt:state.access_jwt 1545 + ~refresh_jwt:state.refresh_jwt ~email:state.email 1546 + ~blobs_imported:new_imported ~blobs_failed:new_failed 1547 + else 1548 + (* more blobs to import, save state *) 1549 + let%lwt () = 1550 + set_migration_state ctx.req 1551 + { state with 1552 + blobs_imported= new_imported 1553 + ; blobs_failed= new_failed 1554 + ; blobs_cursor= new_cursor } 1555 + in 1556 + Util.render_html ~title:"Migrate Account" 1557 + (module Frontend.MigratePage) 1558 + ~props: 1559 + (make_props ~step:"importing_data" ~did:state.did 1560 + ~handle:state.handle ~old_pds:state.old_pds 1561 + ~blobs_imported:new_imported 1562 + ~blobs_failed:new_failed () ) ) ) 1563 + | "submit_plc_token" -> ( 1564 + match get_migration_state ctx.req with 1565 + | None -> 1566 + render_error "Migration state not found. Please start over." 1567 + | Some state -> ( 1568 + let plc_token = 1569 + List.assoc_opt "plc_token" fields 1570 + |> Option.value ~default:"" |> String.trim 1571 + in 1572 + if String.length plc_token = 0 then 1573 + render_error ~step:"enter_plc_token" ~did:state.did 1574 + ~handle:state.handle ~old_pds:state.old_pds 1575 + "Please enter the PLC token from your email" 1576 + else 1577 + (* Get our recommended credentials *) 1578 + match%lwt 1579 + get_recommended_did_credentials state.did ctx.db 1580 + with 1581 + | Error e -> 1582 + render_error ~step:"enter_plc_token" ~did:state.did 1583 + ~handle:state.handle ~old_pds:state.old_pds 1584 + ("Failed to get credentials: " ^ e) 1585 + | Ok base_credentials -> ( 1586 + (* new rotation keys = current rotation keys - old PDS key(s) + new PDS key *) 1587 + let%lwt merged_credentials = 1588 + let%lwt old_pds_keys = 1589 + match%lwt 1590 + get_remote_recommended_credentials 1591 + ~pds_endpoint:state.old_pds 1592 + ~access_jwt:state.access_jwt 1593 + with 1594 + | Ok creds -> 1595 + Lwt.return creds.rotation_keys 1596 + | Error e -> 1597 + Dream.warning (fun log -> 1598 + log 1599 + "migration %s: failed to get old PDS \ 1600 + credentials: %s" 1601 + state.did e ) ; 1602 + Lwt.return [] 1603 + in 1604 + let%lwt current_keys = 1605 + match%lwt get_plc_rotation_keys ~did:state.did with 1606 + | Ok keys -> 1607 + Lwt.return keys 1608 + | Error _ -> 1609 + Lwt.return [] 1610 + in 1611 + (* remove old PDS key(s) from current keys *) 1612 + let preserved_keys = 1613 + List.filter 1614 + (fun k -> not (List.mem k old_pds_keys)) 1615 + current_keys 1616 + in 1617 + (* then add in new key *) 1618 + let merged_keys = 1619 + preserved_keys @ base_credentials.rotation_keys 1620 + |> List.sort_uniq String.compare 1621 + in 1622 + Lwt.return 1623 + {base_credentials with rotation_keys= merged_keys} 1624 + in 1625 + (* get old pds to sign plc operation *) 1626 + match%lwt 1627 + sign_plc_operation ~pds_endpoint:state.old_pds 1628 + ~access_jwt:state.access_jwt ~token:plc_token 1629 + ~credentials:merged_credentials 1630 + with 1631 + | Error e -> 1632 + render_error ~step:"enter_plc_token" ~did:state.did 1633 + ~handle:state.handle ~old_pds:state.old_pds 1634 + ("Failed to sign PLC operation: " ^ e) 1635 + | Ok signed_operation -> ( 1636 + (* submit plc operation *) 1637 + match%lwt 1638 + submit_plc_operation ~did:state.did 1639 + ~handle:state.handle ~operation:signed_operation 1640 + ctx.db 1641 + with 1642 + | Error e -> 1643 + render_error ~step:"enter_plc_token" ~did:state.did 1644 + ~handle:state.handle ~old_pds:state.old_pds 1645 + ("Failed to submit PLC operation: " ^ e) 1646 + | Ok () -> 1647 + (* log account status before activation *) 1648 + let%lwt () = 1649 + match%lwt 1650 + check_local_account_status ~did:state.did 1651 + with 1652 + | Ok status -> 1653 + Dream.info (fun log -> 1654 + log 1655 + "migration %s: activating account, \ 1656 + imported_blobs=%d/%d" 1657 + state.did status.imported_blobs 1658 + status.expected_blobs ) ; 1659 + Lwt.return_unit 1660 + | Error e -> 1661 + Dream.warning (fun log -> 1662 + log 1663 + "migration %s: failed to check status \ 1664 + before activation: %s" 1665 + state.did e ) ; 1666 + Lwt.return_unit 1667 + in 1668 + (* activate the account *) 1669 + let%lwt () = activate_account state.did ctx.db in 1670 + let%lwt () = Session.log_in_did ctx.req state.did in 1671 + let%lwt () = clear_migration_state ctx.req in 1672 + (* try deactivating old account with current token, refresh if expired *) 1673 + let%lwt deactivation_result = 1674 + match%lwt 1675 + deactivate_old_account 1676 + ~pds_endpoint:state.old_pds 1677 + ~access_jwt:state.access_jwt 1678 + with 1679 + | Ok () -> 1680 + Lwt.return_ok () 1681 + | Error e 1682 + when Util.str_contains ~affix:"401" e 1683 + || Util.str_contains ~affix:"Unauthorized" 1684 + e -> ( 1685 + match%lwt 1686 + refresh_session ~pds_endpoint:state.old_pds 1687 + ~refresh_jwt:state.refresh_jwt 1688 + with 1689 + | Ok tokens -> 1690 + deactivate_old_account 1691 + ~pds_endpoint:state.old_pds 1692 + ~access_jwt:tokens.access_jwt 1693 + | Error refresh_err -> 1694 + Lwt.return_error 1695 + (Printf.sprintf 1696 + "Token expired and refresh failed: %s" 1697 + refresh_err ) ) 1698 + | Error e -> 1699 + Lwt.return_error e 1700 + in 1701 + let ( old_account_deactivated 1702 + , old_account_deactivation_error ) = 1703 + match deactivation_result with 1704 + | Ok () -> 1705 + (true, None) 1706 + | Error e -> 1707 + Dream.warning (fun log -> 1708 + log 1709 + "migration %s: failed to deactivate \ 1710 + old account: %s" 1711 + state.did e ) ; 1712 + (false, Some e) 1713 + in 1714 + Util.render_html ~title:"Migrate Account" 1715 + (module Frontend.MigratePage) 1716 + ~props: 1717 + (make_props ~step:"complete" ~did:state.did 1718 + ~handle:state.handle 1719 + ~blobs_imported:state.blobs_imported 1720 + ~blobs_failed:state.blobs_failed 1721 + ~old_account_deactivated 1722 + ?old_account_deactivation_error 1723 + ~message: 1724 + "Your account has been successfully \ 1725 + migrated!" 1726 + () ) ) ) ) ) 1727 + | "resend_plc_token" -> ( 1728 + match get_migration_state ctx.req with 1729 + | None -> 1730 + render_error "Migration state not found. Please start over." 1731 + | Some state -> ( 1732 + match%lwt 1733 + request_plc_operation_signature ~pds_endpoint:state.old_pds 1734 + ~access_jwt:state.access_jwt 1735 + with 1736 + | Error e -> 1737 + Util.render_html ~title:"Migrate Account" 1738 + (module Frontend.MigratePage) 1739 + ~props: 1740 + (make_props ~step:"enter_plc_token" ~did:state.did 1741 + ~handle:state.handle ~old_pds:state.old_pds 1742 + ~error:("Failed to resend: " ^ e) () ) 1743 + | Ok () -> 1744 + Util.render_html ~title:"Migrate Account" 1745 + (module Frontend.MigratePage) 1746 + ~props: 1747 + (make_props ~step:"enter_plc_token" ~did:state.did 1748 + ~handle:state.handle ~old_pds:state.old_pds 1749 + ~message:"Confirmation code resent! Check your email." 1750 + () ) ) ) 1751 + | "submit_2fa" -> ( 1752 + let identifier = 1753 + List.assoc_opt "identifier" fields 1754 + |> Option.value ~default:"" |> String.trim 1755 + in 1756 + let old_pds = 1757 + List.assoc_opt "old_pds" fields 1758 + |> Option.value ~default:"" |> String.trim 1759 + in 1760 + let auth_factor_token = 1761 + List.assoc_opt "auth_factor_token" fields 1762 + |> Option.value ~default:"" |> String.trim 1763 + in 1764 + let invite_code = 1765 + List.assoc_opt "invite_code" fields |> Option.map String.trim 1766 + in 1767 + let password = 1768 + List.assoc_opt "password" fields |> Option.value ~default:"" 1769 + in 1770 + if String.length auth_factor_token = 0 then 1771 + render_error ~step:"enter_2fa" ~identifier ~old_pds ?invite_code 1772 + "Please enter your authentication code" 1773 + else 1774 + (* re-authenticate with 2fa token *) 1775 + match%lwt resolve_identity identifier with 1776 + | Error e -> 1777 + render_error ~step:"enter_2fa" ~identifier ~old_pds 1778 + ?invite_code e 1779 + | Ok (did, handle, resolved_pds) -> ( 1780 + let pds_endpoint = 1781 + if String.length old_pds > 0 then old_pds 1782 + else resolved_pds 1783 + in 1784 + match%lwt 1785 + create_session_on_pds ~pds_endpoint ~identifier ~password 1786 + ~auth_factor_token () 1787 + with 1788 + | AuthError e -> 1789 + render_error ~step:"enter_2fa" ~identifier 1790 + ~old_pds:pds_endpoint ?invite_code e 1791 + | AuthNeeds2FA -> 1792 + render_error ~step:"enter_2fa" ~identifier 1793 + ~old_pds:pds_endpoint ?invite_code 1794 + "Invalid authentication code. Please try again." 1795 + | AuthSuccess session -> ( 1796 + (* continue with normal migration flow *) 1797 + match%lwt 1798 + get_session ~pds_endpoint ~access_jwt:session.access_jwt 1799 + with 1800 + | Error e -> 1801 + render_error ("Failed to get account info: " ^ e) 1802 + | Ok session_info -> ( 1803 + let is_active = 1804 + match session_info.active with 1805 + | Some false -> 1806 + false 1807 + | _ -> 1808 + true 1809 + in 1810 + if not is_active then 1811 + render_error 1812 + "This account is already deactivated. Cannot \ 1813 + migrate a deactivated account." 1814 + else 1815 + match%lwt 1816 + get_service_auth ~pds_endpoint 1817 + ~access_jwt:session.access_jwt 1818 + with 1819 + | Error e -> 1820 + render_error 1821 + ("Failed to get service authorization: " ^ e) 1822 + | Ok service_auth_token -> ( 1823 + let email = 1824 + match session_info.email with 1825 + | Some e when String.length e > 0 -> 1826 + e 1827 + | _ -> 1828 + Printf.sprintf "%s@%s" did Env.hostname 1829 + in 1830 + match%lwt 1831 + create_migrated_account ~email ~handle 1832 + ~password ~did ~service_auth_token 1833 + ?invite_code ctx.db 1834 + with 1835 + | Error e -> 1836 + render_error e 1837 + | Ok _signing_key_did -> ( 1838 + match%lwt 1839 + fetch_repo ~pds_endpoint 1840 + ~access_jwt:session.access_jwt ~did 1841 + with 1842 + | Error e -> 1843 + render_error 1844 + ("Failed to fetch repository: " ^ e) 1845 + | Ok car_data -> ( 1846 + match%lwt import_repo ~did ~car_data with 1847 + | Error e -> 1848 + render_error e 1849 + | Ok () -> 1850 + transition_to_plc_token_step ~did 1851 + ~handle ~old_pds:pds_endpoint 1852 + ~access_jwt:session.access_jwt 1853 + ~refresh_jwt:session.refresh_jwt 1854 + ~email ~blobs_imported:0 1855 + ~blobs_failed:0 ) ) ) ) ) ) ) 1856 + | "resume_migration" -> ( 1857 + (* resume a previously started migration *) 1858 + let identifier = 1859 + List.assoc_opt "identifier" fields 1860 + |> Option.value ~default:"" |> String.trim 1861 + in 1862 + let password = 1863 + List.assoc_opt "password" fields |> Option.value ~default:"" 1864 + in 1865 + if String.length identifier = 0 then 1866 + render_error ~step:"resume_available" 1867 + "Please enter your handle or DID" 1868 + else if String.length password = 0 then 1869 + render_error ~step:"resume_available" 1870 + "Please enter your password" 1871 + else 1872 + match%lwt resolve_identity identifier with 1873 + | Error e -> 1874 + render_error ~step:"resume_available" e 1875 + | Ok (did, handle, old_pds) -> ( 1876 + match%lwt 1877 + create_session_on_pds ~pds_endpoint:old_pds ~identifier 1878 + ~password () 1879 + with 1880 + | AuthError e -> 1881 + render_error ~step:"resume_available" e 1882 + | AuthNeeds2FA -> 1883 + (* show 2fa form, for resume we go back to credentials *) 1884 + Util.render_html ~title:"Migrate Account" 1885 + (module Frontend.MigratePage) 1886 + ~props: 1887 + (make_props ~step:"enter_2fa" ~identifier ~old_pds ()) 1888 + | AuthSuccess session -> ( 1889 + (* get session info for email *) 1890 + let%lwt email = 1891 + match%lwt 1892 + get_session ~pds_endpoint:old_pds 1893 + ~access_jwt:session.access_jwt 1894 + with 1895 + | Ok info -> 1896 + Lwt.return 1897 + ( match info.email with 1898 + | Some e when String.length e > 0 -> 1899 + e 1900 + | _ -> 1901 + Printf.sprintf "%s@%s" did Env.hostname ) 1902 + | Error _ -> 1903 + Lwt.return (Printf.sprintf "%s@%s" did Env.hostname) 1904 + in 1905 + (* check what state the existing account is in *) 1906 + match%lwt check_resume_state ~did ctx.db with 1907 + | Error e -> 1908 + render_error ~step:"resume_available" ~did ~handle 1909 + ~old_pds e 1910 + | Ok AlreadyActive -> 1911 + (* already active, just log in *) 1912 + let%lwt () = Session.log_in_did ctx.req did in 1913 + Util.render_html ~title:"Migrate Account" 1914 + (module Frontend.MigratePage) 1915 + ~props: 1916 + (make_props ~step:"complete" ~did ~handle 1917 + ~message: 1918 + "Your account is already active! You have \ 1919 + been logged in." 1920 + () ) 1921 + | Ok NeedsActivation -> 1922 + (* identity already points here, just activate *) 1923 + let%lwt () = activate_account did ctx.db in 1924 + let%lwt () = Session.log_in_did ctx.req did in 1925 + let%lwt deactivation_result = 1926 + match%lwt 1927 + deactivate_old_account ~pds_endpoint:old_pds 1928 + ~access_jwt:session.access_jwt 1929 + with 1930 + | Ok () -> 1931 + Lwt.return_ok () 1932 + | Error e 1933 + when Util.str_contains ~affix:"401" e 1934 + || Util.str_contains ~affix:"Unauthorized" e 1935 + -> ( 1936 + match%lwt 1937 + refresh_session ~pds_endpoint:old_pds 1938 + ~refresh_jwt:session.refresh_jwt 1939 + with 1940 + | Ok tokens -> 1941 + deactivate_old_account ~pds_endpoint:old_pds 1942 + ~access_jwt:tokens.access_jwt 1943 + | Error refresh_err -> 1944 + Lwt.return_error 1945 + (Printf.sprintf 1946 + "Token expired and refresh failed: %s" 1947 + refresh_err ) ) 1948 + | Error e -> 1949 + Lwt.return_error e 1950 + in 1951 + let ( old_account_deactivated 1952 + , old_account_deactivation_error ) = 1953 + match deactivation_result with 1954 + | Ok () -> 1955 + (true, None) 1956 + | Error e -> 1957 + Dream.warning (fun log -> 1958 + log 1959 + "migration %s: failed to deactivate old \ 1960 + account: %s" 1961 + did e ) ; 1962 + (false, Some e) 1963 + in 1964 + Util.render_html ~title:"Migrate Account" 1965 + (module Frontend.MigratePage) 1966 + ~props: 1967 + (make_props ~step:"complete" ~did ~handle 1968 + ~old_account_deactivated 1969 + ?old_account_deactivation_error 1970 + ~message: 1971 + "Your account has been activated! Your \ 1972 + identity is pointing to this PDS." 1973 + () ) 1974 + | Ok NeedsPlcUpdate -> 1975 + (* data is imported, need plc update *) 1976 + transition_to_plc_token_step ~did ~handle ~old_pds 1977 + ~access_jwt:session.access_jwt 1978 + ~refresh_jwt:session.refresh_jwt ~email 1979 + ~blobs_imported:0 ~blobs_failed:0 1980 + | Ok NeedsRepoImport | Ok NeedsBlobImport -> ( 1981 + (* need to re-import data *) 1982 + match%lwt 1983 + fetch_repo ~pds_endpoint:old_pds 1984 + ~access_jwt:session.access_jwt ~did 1985 + with 1986 + | Error e -> 1987 + render_error ~did ~handle ~old_pds 1988 + ("Failed to fetch repository: " ^ e) 1989 + | Ok car_data -> ( 1990 + match%lwt import_repo ~did ~car_data with 1991 + | Error e -> 1992 + render_error ~did ~handle ~old_pds e 1993 + | Ok () -> ( 1994 + (* list missing blobs locally *) 1995 + match%lwt 1996 + list_local_missing_blobs ~did ~limit:50 () 1997 + with 1998 + | Error e -> 1999 + Dream.warning (fun log -> 2000 + log 2001 + "migration %s: failed to list missing \ 2002 + blobs: %s" 2003 + did e ) ; 2004 + transition_to_plc_token_step ~did ~handle 2005 + ~old_pds ~access_jwt:session.access_jwt 2006 + ~refresh_jwt:session.refresh_jwt ~email 2007 + ~blobs_imported:0 ~blobs_failed:0 2008 + | Ok (missing_cids, next_cursor) -> 2009 + if List.length missing_cids = 0 then 2010 + transition_to_plc_token_step ~did ~handle 2011 + ~old_pds ~access_jwt:session.access_jwt 2012 + ~refresh_jwt:session.refresh_jwt ~email 2013 + ~blobs_imported:0 ~blobs_failed:0 2014 + else 2015 + let%lwt imported, failed = 2016 + import_blobs_batch ~pds_endpoint:old_pds 2017 + ~access_jwt:session.access_jwt ~did 2018 + ~cids:missing_cids 2019 + in 2020 + let cursor = 2021 + Option.value ~default:"" next_cursor 2022 + in 2023 + if String.length cursor = 0 then 2024 + transition_to_plc_token_step ~did ~handle 2025 + ~old_pds ~access_jwt:session.access_jwt 2026 + ~refresh_jwt:session.refresh_jwt ~email 2027 + ~blobs_imported:imported 2028 + ~blobs_failed:failed 2029 + else 2030 + let%lwt () = 2031 + set_migration_state ctx.req 2032 + { did 2033 + ; handle 2034 + ; old_pds 2035 + ; access_jwt= session.access_jwt 2036 + ; refresh_jwt= session.refresh_jwt 2037 + ; email 2038 + ; blobs_imported= imported 2039 + ; blobs_failed= failed 2040 + ; blobs_cursor= cursor 2041 + ; plc_requested= false } 2042 + in 2043 + Util.render_html ~title:"Migrate Account" 2044 + (module Frontend.MigratePage) 2045 + ~props: 2046 + (make_props ~step:"importing_data" ~did 2047 + ~handle ~old_pds 2048 + ~blobs_imported:imported 2049 + ~blobs_failed:failed () ) ) ) ) ) ) ) 2050 + | _ -> 2051 + render_error "Invalid action" ) 2052 + | _ -> 2053 + render_error "Invalid form submission" )
+17 -10
pegasus/lib/api/identity/getRecommendedDidCredentials.ml
··· 1 - type response = Plc.credentials 1 + type response = Plc.credentials [@@deriving yojson {strict= false}] 2 + 3 + let get_credentials did db = 4 + match%lwt Data_store.get_actor_by_identifier did db with 5 + | None -> 6 + Lwt.return_error "actor not found" 7 + | Some actor -> 8 + actor.signing_key |> Kleidos.parse_multikey_str |> Kleidos.derive_pubkey 9 + |> Kleidos.pubkey_to_did_key 10 + |> (fun did_key -> 11 + Plc.create_did_credentials Env.rotation_key did_key actor.handle ) 12 + |> Lwt.return_ok 2 13 3 14 let handler = 4 15 Xrpc.handler ~auth:Authorization (fun {auth; db; _} -> 5 16 let did = Auth.get_authed_did_exn auth in 6 - match%lwt Data_store.get_actor_by_identifier did db with 7 - | None -> 8 - Errors.internal_error ~msg:"actor not found" () 9 - | Some actor -> 10 - actor.signing_key |> Kleidos.parse_multikey_str 11 - |> Kleidos.derive_pubkey |> Kleidos.pubkey_to_did_key 12 - |> (fun did_key -> 13 - Plc.create_did_credentials Env.rotation_key did_key actor.handle ) 14 - |> Plc.credentials_to_yojson |> Yojson.Safe.to_string |> Dream.json ) 17 + match%lwt get_credentials did db with 18 + | Error msg -> 19 + Errors.internal_error ~msg () 20 + | Ok credentials -> 21 + response_to_yojson credentials |> Yojson.Safe.to_string |> Dream.json )
+8 -25
pegasus/lib/api/identity/submitPlcOperation.ml
··· 12 12 | None -> 13 13 Errors.internal_error ~msg:"actor not found" () 14 14 | Some actor -> ( 15 - let pds_pubkey = 16 - Env.rotation_key |> Kleidos.derive_pubkey 17 - |> Kleidos.pubkey_to_did_key 18 - in 19 - if not (List.mem pds_pubkey op.rotation_keys) then 20 - Errors.invalid_request 21 - "rotation keys must include the PDS public key" ; 22 - ( match List.assoc_opt "atproto_pds" op.services with 23 - | Some {type'; endpoint} 24 - when type' <> "AtprotoPersonalDataServer" 25 - || endpoint <> Env.host_endpoint -> 26 - Errors.invalid_request "invalid atproto_pds service" 27 - | _ -> 28 - () ) ; 29 - let actor_pubkey = 30 - actor.signing_key |> Kleidos.parse_multikey_str 31 - |> Kleidos.derive_pubkey |> Kleidos.pubkey_to_did_key 32 - in 33 - if 34 - List.assoc_opt "atproto" op.verification_methods 35 - <> Some actor_pubkey 36 - then Errors.invalid_request "incorrect atproto signing key" ; 37 - if List.hd op.also_known_as <> "at://" ^ actor.handle then 38 - Errors.invalid_request "incorrect handle" ; 15 + match 16 + Plc.validate_operation ~handle:actor.handle 17 + ~signing_key:actor.signing_key (Operation op) 18 + with 19 + | Ok () -> ( 39 20 match%lwt Plc.submit_operation did (Operation op) with 40 21 | Ok () -> 41 22 let%lwt _ = Sequencer.sequence_identity db ~did () in ··· 46 27 ~msg: 47 28 ( "failed to submit plc operation: " ^ Int.to_string status 48 29 ^ " " ^ msg ) 49 - () ) ) 30 + () ) 31 + | Error e -> 32 + Errors.invalid_request e ) )
+39 -38
pegasus/lib/api/server/checkAccountStatus.ml
··· 10 10 ; imported_blobs: int [@key "importedBlobs"] } 11 11 [@@deriving yojson {strict= false}] 12 12 13 + let get_account_status did = 14 + let%lwt {db= us; commit; actor; _} = Repository.load did in 15 + let%lwt cid, commit = 16 + match commit with 17 + | Some c -> 18 + Lwt.return c 19 + | None -> 20 + User_store.get_commit us |> Lwt.map Option.get 21 + in 22 + let repo_commit, repo_rev = (Cid.to_string cid, commit.rev) in 23 + match%lwt 24 + Lwt.all 25 + [ User_store.count_blocks us 26 + ; User_store.count_records us 27 + ; User_store.count_blobs us 28 + ; User_store.count_referenced_blobs us ] 29 + with 30 + | [block_count; indexed_records; imported_blobs; expected_blobs] -> 31 + (* mst blocks + records + commit *) 32 + let repo_blocks = block_count + indexed_records + 1 in 33 + Lwt.return_ok 34 + { activated= actor.deactivated_at = None 35 + ; valid_did= true 36 + ; repo_commit 37 + ; repo_rev 38 + ; repo_blocks 39 + ; indexed_records 40 + ; private_state_values= 0 41 + ; expected_blobs 42 + ; imported_blobs } 43 + | _ -> 44 + Lwt.return_error "failed to load account data" 45 + 13 46 let handler = 14 - Xrpc.handler (fun {db; auth; _} -> 47 + Xrpc.handler (fun {auth; _} -> 15 48 let did = Auth.get_authed_did_exn auth in 16 - match%lwt Data_store.get_actor_by_identifier did db with 17 - | None -> 18 - Errors.internal_error ~msg:"actor not found" () 19 - | Some actor -> ( 20 - let%lwt {db= us; commit; _} = 21 - Repository.load did 22 - in 23 - let%lwt cid, commit = 24 - match commit with 25 - | Some c -> 26 - Lwt.return c 27 - | None -> 28 - User_store.get_commit us |> Lwt.map Option.get 29 - in 30 - let repo_commit, repo_rev = (Cid.to_string cid, commit.rev) in 31 - match%lwt 32 - Lwt.all 33 - [ User_store.count_blocks us 34 - ; User_store.count_records us 35 - ; User_store.count_blobs us 36 - ; User_store.count_referenced_blobs us ] 37 - with 38 - | [block_count; indexed_records; imported_blobs; expected_blobs] -> 39 - (* mst blocks + records + commit *) 40 - let repo_blocks = block_count + indexed_records + 1 in 41 - { activated= actor.deactivated_at <> None 42 - ; valid_did= true 43 - ; repo_commit 44 - ; repo_rev 45 - ; repo_blocks 46 - ; indexed_records 47 - ; private_state_values= 0 48 - ; expected_blobs 49 - ; imported_blobs } 50 - |> response_to_yojson |> Yojson.Safe.to_string |> Dream.json 51 - | _ -> 52 - Errors.internal_error ~msg:"failed to load account data" () ) ) 49 + match%lwt get_account_status did with 50 + | Ok status -> 51 + status |> response_to_yojson |> Yojson.Safe.to_string |> Dream.json 52 + | Error msg -> 53 + Errors.internal_error ~msg () )
+4 -4
pegasus/lib/api/server/getSession.ml
··· 1 - type response = Auth.session_info 1 + type response = Auth.session_info [@@deriving yojson {strict= false}] 2 2 3 3 let handler = 4 4 Xrpc.handler ~auth:Authorization (fun {db; auth; _} -> ··· 9 9 if Auth.allows_email_read auth then session 10 10 else 11 11 { session with 12 - email= "" 13 - ; email_confirmed= false 14 - ; email_auth_factor= false } 12 + email= Some "" 13 + ; email_confirmed= Some false 14 + ; email_auth_factor= Some false } 15 15 in 16 16 Dream.json @@ Yojson.Safe.to_string @@ Auth.session_info_to_yojson session )
+22 -92
pegasus/lib/auth.ml
··· 3 3 type session_info = 4 4 { handle: string 5 5 ; did: string 6 - ; email: string 7 - ; email_confirmed: bool [@key "emailConfirmed"] 8 - ; email_auth_factor: bool [@key "emailAuthFactor"] 6 + ; email: string option 7 + ; email_confirmed: bool option [@key "emailConfirmed"] 8 + ; email_auth_factor: bool option [@key "emailAuthFactor"] 9 9 ; active: bool option [@default None] 10 10 ; status: string option [@default None] } 11 11 [@@deriving yojson {strict= false}] ··· 149 149 Lwt.return 150 150 { did= actor.did 151 151 ; handle= actor.handle 152 - ; email= actor.email 153 - ; email_confirmed= true 154 - ; email_auth_factor= true 152 + ; email= Some actor.email 153 + ; email_confirmed= Some (actor.email_confirmed_at <> None) 154 + ; email_auth_factor= Some true 155 155 ; active 156 156 ; status } 157 157 ··· 410 410 | Error e -> 411 411 Lwt.return_error @@ Errors.auth_required e 412 412 | Ok token -> ( 413 - match Jwt.decode_jwt token with 414 - | Error e -> 415 - Lwt.return_error @@ Errors.auth_required e 416 - | Ok (_header, payload) -> ( 417 - try 418 - let open Yojson.Safe.Util in 419 - let iss = payload |> member "iss" |> to_string in 420 - let aud = payload |> member "aud" |> to_string in 421 - let exp = payload |> member "exp" |> to_int in 422 - let lxm = payload |> member "lxm" |> to_string_option in 423 - let now = int_of_float (Unix.gettimeofday ()) in 424 - if exp < now then 425 - Lwt.return_error 426 - @@ Errors.invalid_request ~name:"ExpiredToken" "token expired" 427 - else if aud <> Env.did then 428 - Lwt.return_error 429 - @@ Errors.invalid_request ~name:"InvalidToken" 430 - "jwt audience does not match service did" 431 - else 432 - let nsid = 433 - (Dream.path [@warning "-3"]) req |> List.rev |> List.hd 434 - in 435 - match lxm with 436 - | Some l when l <> nsid && l <> "*" -> 437 - Lwt.return_error 438 - @@ Errors.invalid_request ~name:"InvalidToken" 439 - ("jwt lxm " ^ l ^ " does not match " ^ nsid) 440 - | _ -> ( 441 - let did = 442 - match String.split_on_char '#' iss with 443 - | did :: _ -> 444 - did 445 - | [] -> 446 - iss 447 - in 448 - match%lwt Id_resolver.Did.resolve did with 449 - | Error e -> 450 - Dream.debug (fun log -> 451 - log "failed to resolve did %s: %s" did e ) ; 452 - Lwt.return_error 453 - @@ Errors.internal_error 454 - ~msg:"could not resolve jwt issuer did" () 455 - | Ok did_doc -> ( 456 - match 457 - Id_resolver.Did.Document.get_verification_key did_doc 458 - "#atproto" 459 - with 460 - | None -> 461 - Lwt.return_error 462 - @@ Errors.internal_error 463 - ~msg:"missing or bad key in issuer did doc" () 464 - | Some pubkey_multibase -> ( 465 - match%lwt 466 - verify_with_key token pubkey_multibase did db 467 - with 468 - | Ok creds -> 469 - Lwt.return_ok creds 470 - | Error _ -> ( 471 - (* try again, skipping cache in case of key rotation *) 472 - match%lwt 473 - Id_resolver.Did.resolve ~skip_cache:true did 474 - with 475 - | Error _ -> 476 - Lwt.return_error 477 - @@ Errors.invalid_request ~name:"InvalidToken" 478 - "jwt signature does not match jwt issuer" 479 - | Ok fresh_doc -> ( 480 - match 481 - Id_resolver.Did.Document.get_verification_key 482 - fresh_doc "#atproto" 483 - with 484 - | None -> 485 - Lwt.return_error 486 - @@ Errors.invalid_request ~name:"InvalidToken" 487 - "jwt signature does not match jwt issuer" 488 - | Some fresh_pubkey_multibase 489 - when fresh_pubkey_multibase = pubkey_multibase -> 490 - Lwt.return_error 491 - @@ Errors.invalid_request ~name:"InvalidToken" 492 - "jwt signature does not match jwt issuer" 493 - | Some fresh_pubkey_multibase -> 494 - verify_with_key token fresh_pubkey_multibase did 495 - db ) ) ) ) ) 496 - with _ -> 497 - Lwt.return_error @@ Errors.invalid_request "malformed service jwt" ) 498 - ) 413 + let nsid = (Dream.path [@warning "-3"]) req |> List.rev |> List.hd in 414 + match%lwt 415 + Jwt.verify_service_jwt ~nsid 416 + ~verify_sig:(fun did pk -> verify_with_key token pk did db) 417 + token 418 + with 419 + | Ok creds -> 420 + Lwt.return_ok creds 421 + | Error (AuthRequired e) -> 422 + Lwt.return_error @@ Errors.auth_required e 423 + | Error (ExpiredToken e) -> 424 + Lwt.return_error @@ Errors.invalid_request ~name:"ExpiredToken" e 425 + | Error (InvalidToken e) -> 426 + Lwt.return_error @@ Errors.invalid_request ~name:"InvalidToken" e 427 + | Error (InternalError e) -> 428 + Lwt.return_error @@ Errors.internal_error ~msg:e () ) 499 429 500 430 let authorization : verifier = 501 431 fun ctx ->
+14
pegasus/lib/errors.ml
··· 32 32 33 33 let use_dpop_nonce () = raise UseDpopNonceError 34 34 35 + let printer = function 36 + | InvalidRequestError (error, message) -> 37 + Some (Printf.sprintf "Invalid request (%s): %s" error message) 38 + | InternalServerError (error, message) -> 39 + Some (Printf.sprintf "Internal server error (%s): %s" error message) 40 + | AuthError (error, message) -> 41 + Some (Printf.sprintf "Auth error (%s): %s" error message) 42 + | NotFoundError (error, message) -> 43 + Some (Printf.sprintf "Not found (%s): %s" error message) 44 + | UseDpopNonceError -> 45 + Some "Use DPoP nonce" 46 + | _ -> 47 + None 48 + 35 49 let exn_to_response exn = 36 50 let format_response error msg status = 37 51 Dream.json ~status @@ Yojson.Safe.to_string
+85
pegasus/lib/jwt.ml
··· 120 120 let exp = now_s + Defaults.service_token_exp in 121 121 let payload = service_jwt_to_yojson {iss= did; aud; lxm; exp} in 122 122 sign_jwt payload ~signing_key 123 + 124 + type verify_jwt_error = 125 + | AuthRequired of string 126 + | ExpiredToken of string 127 + | InvalidToken of string 128 + | InternalError of string 129 + 130 + (* if no did is provided, iss did will be assumed to be correct *) 131 + let verify_service_jwt ~nsid ?did ~(verify_sig : string -> string -> 'a) token = 132 + match decode_jwt token with 133 + | Error e -> 134 + Lwt.return_error @@ AuthRequired e 135 + | Ok (_header, payload) -> ( 136 + try 137 + let open Yojson.Safe.Util in 138 + let iss = payload |> member "iss" |> to_string in 139 + let aud = payload |> member "aud" |> to_string in 140 + let exp = payload |> member "exp" |> to_int in 141 + let lxm = payload |> member "lxm" |> to_string_option in 142 + let now = int_of_float (Unix.gettimeofday ()) in 143 + if exp < now then Lwt.return_error @@ ExpiredToken "token expired" 144 + else if aud <> Env.did then 145 + Lwt.return_error 146 + @@ InvalidToken "jwt audience does not match service did" 147 + else 148 + let iss_did = 149 + match String.split_on_char '#' iss with did :: _ -> did | [] -> iss 150 + in 151 + if did <> None && Some iss_did <> did then 152 + Lwt.return_error @@ InvalidToken "jwt issuer does not match did" 153 + else 154 + match lxm with 155 + | Some l when l <> nsid && l <> "*" -> 156 + Lwt.return_error 157 + @@ InvalidToken ("jwt lxm " ^ l ^ " does not match " ^ nsid) 158 + | _ -> ( 159 + let did = Option.value did ~default:iss_did in 160 + match%lwt Id_resolver.Did.resolve did with 161 + | Error e -> 162 + Dream.debug (fun log -> 163 + log "failed to resolve did %s: %s" did e ) ; 164 + Lwt.return_error 165 + @@ InternalError "could not resolve jwt issuer did" 166 + | Ok did_doc -> ( 167 + match 168 + Id_resolver.Did.Document.get_verification_key did_doc 169 + "#atproto" 170 + with 171 + | None -> 172 + Lwt.return_error 173 + @@ InternalError "missing or bad key in issuer did doc" 174 + | Some pubkey_multibase -> ( 175 + match%lwt verify_sig did pubkey_multibase with 176 + | Ok creds -> 177 + Lwt.return_ok creds 178 + | Error _ -> ( 179 + (* try again, skipping cache in case of key rotation *) 180 + match%lwt 181 + Id_resolver.Did.resolve ~skip_cache:true did 182 + with 183 + | Error _ -> 184 + Lwt.return_error 185 + @@ InvalidToken 186 + "jwt signature does not match jwt issuer" 187 + | Ok fresh_doc -> ( 188 + match 189 + Id_resolver.Did.Document.get_verification_key fresh_doc 190 + "#atproto" 191 + with 192 + | None -> 193 + Lwt.return_error 194 + @@ InvalidToken 195 + "jwt signature does not match jwt issuer" 196 + | Some fresh_pubkey_multibase 197 + when fresh_pubkey_multibase = pubkey_multibase -> 198 + Lwt.return_error 199 + @@ InvalidToken 200 + "jwt signature does not match jwt issuer" 201 + | Some fresh_pubkey_multibase -> ( 202 + match%lwt verify_sig did fresh_pubkey_multibase with 203 + | Ok creds -> 204 + Lwt.return_ok creds 205 + | Error e -> 206 + Lwt.return_error @@ InternalError e ) ) ) ) ) ) 207 + with _ -> Lwt.return_error @@ InvalidToken "malformed service jwt" )
+32
pegasus/lib/plc.ml
··· 165 165 let%lwt body_str = Body.to_string body in 166 166 Lwt.return_error (Http.Status.to_int res.status, body_str) 167 167 168 + let validate_operation ~handle ?signing_key (op : signed_operation) = 169 + let pds_pubkey = 170 + Env.rotation_key |> Kleidos.derive_pubkey |> Kleidos.pubkey_to_did_key 171 + in 172 + match op with 173 + | Operation op -> ( 174 + if not (List.mem pds_pubkey op.rotation_keys) then 175 + Error "rotation keys must include the PDS public key" 176 + else 177 + match List.assoc_opt "atproto_pds" op.services with 178 + | Some {type'; endpoint} 179 + when type' <> "AtprotoPersonalDataServer" 180 + || endpoint <> Env.host_endpoint -> 181 + Error "invalid atproto_pds service" 182 + | _ -> 183 + let actor_pubkey = 184 + signing_key 185 + |> Option.map (fun sk -> 186 + sk |> Kleidos.parse_multikey_str |> Kleidos.derive_pubkey 187 + |> Kleidos.pubkey_to_did_key ) 188 + in 189 + if 190 + actor_pubkey <> None 191 + && List.assoc_opt "atproto" op.verification_methods 192 + <> actor_pubkey 193 + then Error "incorrect atproto signing key" 194 + else if List.hd op.also_known_as <> "at://" ^ handle then 195 + Error "incorrect handle" 196 + else Ok () ) 197 + | Tombstone _ -> 198 + Ok () 199 + 168 200 let did_of_operation operation : string = 169 201 let cbor = signed_operation_to_yojson operation |> Dag_cbor.encode_yojson in 170 202 let digest = Digestif.SHA256.(cbor |> digest_bytes |> to_raw_string) in
+4 -3
pegasus/lib/repository.ml
··· 146 146 type t = 147 147 { key: Kleidos.key 148 148 ; did: string 149 + ; actor: Data_store.Types.actor 149 150 ; db: User_store.t 150 151 ; mutable commit: (Cid.t * signed_commit) option } 151 152 ··· 422 423 Errors.invalid_request ~name:"RepoNotFound" 423 424 "your princess is in another castle" 424 425 in 425 - let%lwt {signing_key; _} = 426 + let%lwt actor = 426 427 match%lwt Data_store.get_actor_by_identifier did ds_conn with 427 428 | Some actor when ensure_active = false || actor.deactivated_at = None -> 428 429 Lwt.return actor ··· 432 433 | None -> 433 434 failwith ("failed to retrieve actor for " ^ did) 434 435 in 435 - let key = Kleidos.parse_multikey_str signing_key in 436 + let key = Kleidos.parse_multikey_str actor.signing_key in 436 437 let%lwt commit = User_store.get_commit user_db in 437 - Lwt.return {key; did; db= user_db; commit} 438 + Lwt.return {key; did; actor; db= user_db; commit} 438 439 439 440 let export_car t : Car.stream Lwt.t = 440 441 let%lwt root, commit =
+7
pegasus/lib/util.ml
··· 460 460 headers ) 461 461 (Http.Header.init ()) headers 462 462 463 + let str_contains ~affix str = 464 + let re = Str.regexp_string affix in 465 + try 466 + ignore (Str.search_forward re str 0) ; 467 + true 468 + with Not_found -> false 469 + 463 470 module type Template = sig 464 471 type props 465 472
+5
public/main.css
··· 64 64 --shadow-shimmer: inset 0 0 1em #90bbeea6; 65 65 --shadow-glow: inset 0 0 1.25em #2d37ba80; 66 66 --shadow-bleed: inset 0 0 1.25em #db4c6473; 67 + --shadow-elixir: 68 + 0 0 3px 1px rgba(166, 144, 241, 0.25), 69 + -1px 2px 8px 0 rgba(141, 109, 216, 0.7) inset, 70 + 1px 3px 8px 0 rgba(184, 176, 219, 0.8) inset, 71 + 2px -2px 8px 0 rgba(68, 57, 119, 0.9) inset; 67 72 }