Graphical PDS migrator for AT Protocol

feat: enhance PLC update process with new steps and error handling

Turtlepaw d2142635 c3dfe73f

Changed files
+570 -163
islands
lib
routes
api
+420 -74
islands/DidPlcProgress.tsx
··· 2 2 3 3 interface PlcUpdateStep { 4 4 name: string; 5 - status: "pending" | "in-progress" | "completed" | "error"; 5 + status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 6 6 error?: string; 7 7 } 8 8 ··· 10 10 const [hasStarted, setHasStarted] = useState(false); 11 11 const [steps, setSteps] = useState<PlcUpdateStep[]>([ 12 12 { name: "Generate PLC key", status: "pending" }, 13 - { name: "Update PLC key", status: "pending" }, 13 + { name: "Start PLC update", status: "pending" }, 14 + { name: "Complete PLC update", status: "pending" }, 14 15 ]); 15 16 const [generatedKey, setGeneratedKey] = useState<string>(""); 16 - const [updateKey, setUpdateKey] = useState<string>(""); 17 + const [keyJson, setKeyJson] = useState<any>(null); 18 + const [emailToken, setEmailToken] = useState<string>(""); 17 19 const [updateResult, setUpdateResult] = useState<string>(""); 20 + const [showDownload, setShowDownload] = useState(false); 21 + const [showKeyInfo, setShowKeyInfo] = useState(false); 18 22 19 23 const updateStepStatus = ( 20 24 index: number, 21 25 status: PlcUpdateStep["status"], 22 26 error?: string 23 27 ) => { 28 + console.log( 29 + `Updating step ${index} to ${status}${ 30 + error ? ` with error: ${error}` : "" 31 + }` 32 + ); 24 33 setSteps((prevSteps) => 25 34 prevSteps.map((step, i) => 26 - i === index ? { ...step, status, error } : step 35 + i === index 36 + ? { ...step, status, error } 37 + : i > index 38 + ? { ...step, status: "pending", error: undefined } 39 + : step 27 40 ) 28 41 ); 29 42 }; 30 43 31 44 const handleStart = () => { 32 45 setHasStarted(true); 46 + // Automatically start the first step 47 + setTimeout(() => { 48 + handleGenerateKey(); 49 + }, 100); 50 + }; 51 + 52 + const getStepDisplayName = (step: PlcUpdateStep, index: number) => { 53 + if (step.status === "completed") { 54 + switch (index) { 55 + case 0: 56 + return "PLC Key Generated"; 57 + case 1: 58 + return "PLC Update Started"; 59 + case 2: 60 + return "PLC Update Completed"; 61 + } 62 + } 63 + 64 + if (step.status === "in-progress") { 65 + switch (index) { 66 + case 0: 67 + return "Generating PLC key..."; 68 + case 1: 69 + return "Starting PLC update..."; 70 + case 2: 71 + return step.name === 72 + "Enter the token sent to your email to complete PLC update" 73 + ? step.name 74 + : "Completing PLC update..."; 75 + } 76 + } 77 + 78 + if (step.status === "verifying") { 79 + switch (index) { 80 + case 0: 81 + return "Verifying key generation..."; 82 + case 1: 83 + return "Verifying PLC update start..."; 84 + case 2: 85 + return "Verifying PLC update completion..."; 86 + } 87 + } 88 + 89 + return step.name; 33 90 }; 34 91 35 92 const handleGenerateKey = async () => { 36 93 updateStepStatus(0, "in-progress"); 94 + setShowDownload(false); 95 + setKeyJson(null); 96 + setGeneratedKey(""); 37 97 try { 38 98 const res = await fetch("/api/plc/keys"); 39 99 const text = await res.text(); ··· 51 111 } catch { 52 112 throw new Error("Invalid response from /api/plc/keys"); 53 113 } 54 - if (!data.did || !data.signature) { 55 - throw new Error("Key generation failed: missing did or signature"); 114 + if (!data.publicKeyDid || !data.privateKeyHex) { 115 + throw new Error("Key generation failed: missing key data"); 56 116 } 57 - setGeneratedKey(data.did); 58 - setUpdateKey(data.did); 117 + setGeneratedKey(data.publicKeyDid); 118 + setKeyJson(data); 119 + setShowDownload(true); 59 120 updateStepStatus(0, "completed"); 121 + 122 + // Auto-download the key 123 + setTimeout(() => { 124 + console.log("Attempting auto-download with keyJson:", keyJson); 125 + handleDownload(); 126 + }, 500); 127 + 128 + // Auto-continue to next step with the generated key 129 + setTimeout(() => { 130 + handleStartPlcUpdate(data.publicKeyDid); 131 + }, 1000); 60 132 } catch (error) { 61 133 updateStepStatus( 62 134 0, ··· 66 138 } 67 139 }; 68 140 69 - const handleUpdateKey = async () => { 141 + const handleStartPlcUpdate = async (keyToUse?: string) => { 142 + const key = keyToUse || generatedKey; 143 + if (!key) { 144 + console.log("No key generated yet", { key, generatedKey }); 145 + updateStepStatus(1, "error", "No key generated yet"); 146 + return; 147 + } 148 + 70 149 updateStepStatus(1, "in-progress"); 71 - setUpdateResult(""); 72 150 try { 73 151 const res = await fetch("/api/plc/update", { 74 152 method: "POST", 75 153 headers: { "Content-Type": "application/json" }, 76 - body: JSON.stringify({ key: updateKey }), 154 + body: JSON.stringify({ key: key }), 77 155 }); 78 156 const text = await res.text(); 79 157 if (!res.ok) { 80 158 try { 81 159 const json = JSON.parse(text); 82 - throw new Error(json.message || "Failed to update key"); 160 + throw new Error(json.message || "Failed to start PLC update"); 83 161 } catch { 84 - throw new Error(text || "Failed to update key"); 162 + throw new Error(text || "Failed to start PLC update"); 85 163 } 86 164 } 87 - setUpdateResult("Key updated successfully!"); 165 + 166 + // Update step name to prompt for token 167 + setSteps((prevSteps) => 168 + prevSteps.map((step, i) => 169 + i === 1 170 + ? { 171 + ...step, 172 + name: "Enter the token sent to your email to complete PLC update", 173 + } 174 + : step 175 + ) 176 + ); 88 177 updateStepStatus(1, "completed"); 89 178 } catch (error) { 90 179 updateStepStatus( ··· 92 181 "error", 93 182 error instanceof Error ? error.message : String(error) 94 183 ); 184 + } 185 + }; 186 + 187 + const handleCompletePlcUpdate = async () => { 188 + if (!emailToken) { 189 + updateStepStatus(2, "error", "Please enter the email token"); 190 + return; 191 + } 192 + 193 + updateStepStatus(2, "in-progress"); 194 + try { 195 + const res = await fetch( 196 + `/api/plc/update/complete?token=${encodeURIComponent(emailToken)}`, 197 + { 198 + method: "POST", 199 + headers: { "Content-Type": "application/json" }, 200 + } 201 + ); 202 + const text = await res.text(); 203 + if (!res.ok) { 204 + try { 205 + const json = JSON.parse(text); 206 + throw new Error(json.message || "Failed to complete PLC update"); 207 + } catch { 208 + throw new Error(text || "Failed to complete PLC update"); 209 + } 210 + } 211 + 212 + let data; 213 + try { 214 + data = JSON.parse(text); 215 + if (!data.success) { 216 + throw new Error(data.message || "PLC update failed"); 217 + } 218 + } catch { 219 + throw new Error("Invalid response from server"); 220 + } 221 + 222 + setUpdateResult("PLC update completed successfully!"); 223 + updateStepStatus(2, "completed"); 224 + } catch (error) { 225 + updateStepStatus( 226 + 2, 227 + "error", 228 + error instanceof Error ? error.message : String(error) 229 + ); 95 230 setUpdateResult(error instanceof Error ? error.message : String(error)); 96 231 } 97 232 }; 98 233 234 + const handleDownload = () => { 235 + console.log("handleDownload called with keyJson:", keyJson); 236 + if (!keyJson) { 237 + console.error("No key JSON to download"); 238 + return; 239 + } 240 + try { 241 + const jsonString = JSON.stringify(keyJson, null, 2); 242 + console.log("JSON string to download:", jsonString); 243 + const blob = new Blob([jsonString], { 244 + type: "application/json", 245 + }); 246 + const url = URL.createObjectURL(blob); 247 + const a = document.createElement("a"); 248 + a.href = url; 249 + a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`; 250 + a.style.display = "none"; 251 + document.body.appendChild(a); 252 + console.log("Download link created, clicking..."); 253 + a.click(); 254 + document.body.removeChild(a); 255 + URL.revokeObjectURL(url); 256 + console.log("Key downloaded successfully:", keyJson.publicKeyDid); 257 + } catch (error) { 258 + console.error("Download failed:", error); 259 + } 260 + }; 261 + 262 + const getStepIcon = (status: PlcUpdateStep["status"]) => { 263 + switch (status) { 264 + case "pending": 265 + return ( 266 + <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 267 + <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 268 + </div> 269 + ); 270 + case "in-progress": 271 + return ( 272 + <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 273 + <div class="w-3 h-3 rounded-full bg-blue-500" /> 274 + </div> 275 + ); 276 + case "verifying": 277 + return ( 278 + <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 279 + <div class="w-3 h-3 rounded-full bg-yellow-500" /> 280 + </div> 281 + ); 282 + case "completed": 283 + return ( 284 + <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 285 + <svg 286 + class="w-5 h-5 text-white" 287 + fill="none" 288 + stroke="currentColor" 289 + viewBox="0 0 24 24" 290 + > 291 + <path 292 + stroke-linecap="round" 293 + stroke-linejoin="round" 294 + stroke-width="2" 295 + d="M5 13l4 4L19 7" 296 + /> 297 + </svg> 298 + </div> 299 + ); 300 + case "error": 301 + return ( 302 + <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 303 + <svg 304 + class="w-5 h-5 text-white" 305 + fill="none" 306 + stroke="currentColor" 307 + viewBox="0 0 24 24" 308 + > 309 + <path 310 + stroke-linecap="round" 311 + stroke-linejoin="round" 312 + stroke-width="2" 313 + d="M6 18L18 6M6 6l12 12" 314 + /> 315 + </svg> 316 + </div> 317 + ); 318 + } 319 + }; 320 + 321 + const getStepClasses = (status: PlcUpdateStep["status"]) => { 322 + const baseClasses = 323 + "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 324 + switch (status) { 325 + case "pending": 326 + return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 327 + case "in-progress": 328 + return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 329 + case "verifying": 330 + return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 331 + case "completed": 332 + return `${baseClasses} bg-green-50 dark:bg-green-900`; 333 + case "error": 334 + return `${baseClasses} bg-red-50 dark:bg-red-900`; 335 + } 336 + }; 337 + 99 338 if (!hasStarted) { 100 339 return ( 101 340 <div class="space-y-6"> ··· 111 350 <p> 112 351 • Generate a new PLC key with cryptographic signature verification 113 352 </p> 114 - <p>• Update your existing DID with the new key</p> 353 + <p>• Start PLC update process (sends email with token)</p> 354 + <p>• Complete PLC update using email token</p> 115 355 <p>• All operations require authentication</p> 116 356 </div> 117 357 <button ··· 127 367 128 368 return ( 129 369 <div class="space-y-8"> 370 + {/* Steps Section */} 130 371 <div class="space-y-4"> 131 - {/* Step 1: Generate PLC key */} 132 - <div 133 - class={`flex items-center space-x-3 p-4 rounded-lg ${ 134 - steps[0].status === "completed" 135 - ? "bg-green-50 dark:bg-green-900" 136 - : steps[0].status === "in-progress" 137 - ? "bg-blue-50 dark:bg-blue-900" 138 - : steps[0].status === "error" 139 - ? "bg-red-50 dark:bg-red-900" 140 - : "bg-gray-50 dark:bg-gray-800" 141 - }`} 142 - > 372 + <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100"> 373 + PLC Update Process 374 + </h3> 375 + {steps.map((step, index) => ( 376 + <div key={step.name} class={getStepClasses(step.status)}> 377 + {getStepIcon(step.status)} 378 + <div class="flex-1"> 379 + <p 380 + class={`font-medium ${ 381 + step.status === "error" 382 + ? "text-red-900 dark:text-red-200" 383 + : step.status === "completed" 384 + ? "text-green-900 dark:text-green-200" 385 + : step.status === "in-progress" 386 + ? "text-blue-900 dark:text-blue-200" 387 + : "text-gray-900 dark:text-gray-200" 388 + }`} 389 + > 390 + {getStepDisplayName(step, index)} 391 + </p> 392 + {step.error && ( 393 + <p class="text-sm text-red-600 dark:text-red-400 mt-1"> 394 + {(() => { 395 + try { 396 + const err = JSON.parse(step.error); 397 + return err.message || step.error; 398 + } catch { 399 + return step.error; 400 + } 401 + })()} 402 + </p> 403 + )} 404 + {index === 1 && step.status === "completed" && ( 405 + <div class="mt-4"> 406 + <button 407 + type="button" 408 + onClick={() => handleStartPlcUpdate()} 409 + class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors duration-200" 410 + > 411 + Retry PLC Update 412 + </button> 413 + </div> 414 + )} 415 + {index === 1 && 416 + step.status === "in-progress" && 417 + step.name === 418 + "Enter the token sent to your email to complete PLC update" && ( 419 + <div class="mt-4 space-y-4"> 420 + <p class="text-sm text-blue-800 dark:text-blue-200"> 421 + Please check your email for the PLC update token and enter 422 + it below: 423 + </p> 424 + <div class="flex space-x-2"> 425 + <input 426 + type="text" 427 + value={emailToken} 428 + onChange={(e) => setEmailToken(e.currentTarget.value)} 429 + placeholder="Enter token" 430 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400" 431 + /> 432 + <button 433 + type="button" 434 + onClick={handleCompletePlcUpdate} 435 + class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200" 436 + > 437 + Submit Token 438 + </button> 439 + </div> 440 + </div> 441 + )} 442 + </div> 443 + </div> 444 + ))} 445 + </div> 446 + 447 + {/* Key Information Section - Collapsible at bottom */} 448 + {keyJson && ( 449 + <div class="border border-gray-200 dark:border-gray-700 rounded-lg"> 143 450 <button 144 - class="px-4 py-2 bg-blue-600 text-white rounded-md" 145 - onClick={handleGenerateKey} 146 - disabled={steps[0].status === "in-progress"} 451 + onClick={() => setShowKeyInfo(!showKeyInfo)} 452 + class="w-full p-4 text-left bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg flex items-center justify-between" 147 453 > 148 - Generate PLC Key 454 + <span class="font-medium text-gray-900 dark:text-gray-100"> 455 + Generated Key Information 456 + </span> 457 + <svg 458 + class={`w-5 h-5 text-gray-500 transition-transform ${ 459 + showKeyInfo ? "rotate-180" : "" 460 + }`} 461 + fill="none" 462 + stroke="currentColor" 463 + viewBox="0 0 24 24" 464 + > 465 + <path 466 + stroke-linecap="round" 467 + stroke-linejoin="round" 468 + stroke-width="2" 469 + d="M19 9l-7 7-7-7" 470 + /> 471 + </svg> 149 472 </button> 150 - {steps[0].status === "completed" && ( 151 - <span class="text-green-700 ml-4">Key generated!</span> 152 - )} 153 - {steps[0].status === "error" && ( 154 - <span class="text-red-700 ml-4">{steps[0].error}</span> 473 + {showKeyInfo && ( 474 + <div class="p-4 bg-white dark:bg-gray-900 rounded-b-lg"> 475 + <div class="space-y-3 text-sm text-gray-700 dark:text-gray-300"> 476 + <div> 477 + <b>Key type:</b> {keyJson.keyType} 478 + </div> 479 + <div> 480 + <b>Public key (did:key):</b>{" "} 481 + <span class="break-all font-mono"> 482 + {keyJson.publicKeyDid} 483 + </span> 484 + </div> 485 + <div> 486 + <b>Private key (hex):</b>{" "} 487 + <span class="break-all font-mono"> 488 + {keyJson.privateKeyHex} 489 + </span> 490 + </div> 491 + <div> 492 + <b>Private key (multikey):</b>{" "} 493 + <span class="break-all font-mono"> 494 + {keyJson.privateKeyMultikey} 495 + </span> 496 + </div> 497 + </div> 498 + <div class="mt-4"> 499 + <button 500 + class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm" 501 + onClick={handleDownload} 502 + > 503 + Download Key JSON 504 + </button> 505 + </div> 506 + </div> 155 507 )} 156 508 </div> 157 - {generatedKey && ( 158 - <div class="p-2 bg-gray-100 dark:bg-gray-700 rounded"> 159 - <div class="text-xs text-gray-700 dark:text-gray-200 break-all"> 160 - <b>Generated DID:</b> {generatedKey} 161 - </div> 162 - </div> 163 - )} 164 - {/* Step 2: Update PLC key */} 165 - <div 166 - class={`flex flex-col space-y-2 p-4 rounded-lg ${ 167 - steps[1].status === "completed" 168 - ? "bg-green-50 dark:bg-green-900" 169 - : steps[1].status === "in-progress" 170 - ? "bg-blue-50 dark:bg-blue-900" 171 - : steps[1].status === "error" 172 - ? "bg-red-50 dark:bg-red-900" 173 - : "bg-gray-50 dark:bg-gray-800" 174 - }`} 175 - > 176 - <label class="text-sm mb-1">DID to update:</label> 177 - <input 178 - class="p-2 rounded border border-gray-300 dark:border-gray-600" 179 - type="text" 180 - value={updateKey} 181 - onInput={(e) => setUpdateKey(e.currentTarget.value)} 182 - placeholder="Paste or use generated DID" 183 - /> 509 + )} 510 + 511 + {steps[2].status === "completed" && ( 512 + <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 513 + <p class="text-sm text-green-800 dark:text-green-200"> 514 + PLC update completed successfully! You can now close this page. 515 + </p> 184 516 <button 185 - class="mt-2 px-4 py-2 bg-blue-600 text-white rounded-md" 186 - onClick={handleUpdateKey} 187 - disabled={steps[1].status === "in-progress" || !updateKey} 517 + type="button" 518 + onClick={async () => { 519 + try { 520 + const response = await fetch("/api/logout", { 521 + method: "POST", 522 + credentials: "include", 523 + }); 524 + if (!response.ok) { 525 + throw new Error("Logout failed"); 526 + } 527 + globalThis.location.href = "/"; 528 + } catch (error) { 529 + console.error("Failed to logout:", error); 530 + } 531 + }} 532 + class="mt-4 mr-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200" 188 533 > 189 - Update PLC Key 534 + Sign Out 190 535 </button> 191 - {steps[1].status === "completed" && ( 192 - <span class="text-green-700 mt-2">{updateResult}</span> 193 - )} 194 - {steps[1].status === "error" && ( 195 - <span class="text-red-700 mt-2">{steps[1].error}</span> 196 - )} 536 + <a 537 + href="https://ko-fi.com/knotbin" 538 + target="_blank" 539 + class="mt-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200" 540 + > 541 + Donate 542 + </a> 197 543 </div> 198 - </div> 544 + )} 199 545 </div> 200 546 ); 201 547 }
+21 -29
lib/cred/sessions.ts
··· 17 17 } 18 18 return migrationSessionOptions; 19 19 } 20 - 20 + 21 21 if (!credentialSessionOptions) { 22 22 credentialSessionOptions = await createSessionOptions("cred_sid"); 23 23 } ··· 37 37 isMigration: boolean = false 38 38 ) { 39 39 const options = await getOptions(isMigration); 40 - return getIronSession<CredentialSession>( 41 - req, 42 - res, 43 - options, 44 - ); 40 + return getIronSession<CredentialSession>(req, res, options); 45 41 } 46 42 47 43 /** ··· 54 50 export async function getCredentialAgent( 55 51 req: Request, 56 52 res: Response = new Response(), 57 - isMigration: boolean = false, 53 + isMigration: boolean = false 58 54 ) { 59 - const session = await getCredentialSession( 60 - req, 61 - res, 62 - isMigration 63 - ); 64 - if (!session.did || !session.service || !session.handle || !session.password) { 55 + const session = await getCredentialSession(req, res, isMigration); 56 + if ( 57 + !session.did || 58 + !session.service || 59 + !session.handle || 60 + !session.password 61 + ) { 65 62 return null; 66 63 } 67 64 ··· 107 104 req: Request, 108 105 res: Response, 109 106 data: CredentialSession, 110 - isMigration: boolean = false, 107 + isMigration: boolean = false 111 108 ) { 112 - const session = await getCredentialSession( 113 - req, 114 - res, 115 - isMigration 116 - ); 109 + const session = await getCredentialSession(req, res, isMigration); 117 110 session.did = data.did; 118 111 session.handle = data.handle; 119 112 session.service = data.service; ··· 132 125 export async function getCredentialSessionAgent( 133 126 req: Request, 134 127 res: Response = new Response(), 135 - isMigration: boolean = false, 128 + isMigration: boolean = false 136 129 ) { 137 - const session = await getCredentialSession( 138 - req, 139 - res, 140 - isMigration 141 - ); 130 + const session = await getCredentialSession(req, res, isMigration); 142 131 143 132 console.log("Session state:", { 144 133 hasDid: !!session.did, ··· 147 136 hasPassword: !!session.password, 148 137 hasAccessJwt: !!session.accessJwt, 149 138 service: session.service, 150 - handle: session.handle 139 + handle: session.handle, 151 140 }); 152 141 153 142 if ( 154 - !session.did || !session.service || !session.handle || !session.password 143 + !session.did || 144 + !session.service || 145 + !session.handle || 146 + !session.password 155 147 ) { 156 148 console.log("Missing required session fields"); 157 149 return null; ··· 170 162 const sessionInfo = await agent.com.atproto.server.getSession(); 171 163 console.log("Stored JWT is valid, session info:", { 172 164 did: sessionInfo.data.did, 173 - handle: sessionInfo.data.handle 165 + handle: sessionInfo.data.handle, 174 166 }); 175 167 return agent; 176 168 } catch (err) { ··· 190 182 console.log("Session created successfully:", { 191 183 did: sessionRes.data.did, 192 184 handle: sessionRes.data.handle, 193 - hasAccessJwt: !!sessionRes.data.accessJwt 185 + hasAccessJwt: !!sessionRes.data.accessJwt, 194 186 }); 195 187 196 188 // Store the new token
+12 -12
routes/api/plc/keys.ts
··· 1 1 import { Secp256k1Keypair } from "@atproto/crypto"; 2 2 import { getSessionAgent } from "../../../lib/sessions.ts"; 3 3 import { define } from "../../../utils.ts"; 4 + import * as ui8 from "npm:uint8arrays"; 4 5 5 6 /** 6 7 * Generate and return PLC keys for the authenticated user ··· 15 16 // Create a new keypair 16 17 const keypair = await Secp256k1Keypair.create({ exportable: true }); 17 18 18 - // sign binary data, resulting signature bytes. 19 - // SHA-256 hash of data is what actually gets signed. 20 - // signature output is often base64-encoded. 21 - const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); 22 - const sig = await keypair.sign(data); 19 + // Export private key bytes 20 + const privateKeyBytes = await keypair.export(); 21 + const privateKeyHex = ui8.toString(privateKeyBytes, "hex"); 23 22 24 - // serialize the public key as a did:key string, which includes key type metadata 25 - const pubDidKey = keypair.did(); 26 - console.log(pubDidKey); 23 + // Get public key as DID 24 + const publicKeyDid = keypair.did(); 27 25 28 - // output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38' 26 + // Convert private key to multikey format (base58btc) 27 + const privateKeyMultikey = ui8.toString(privateKeyBytes, "base58btc"); 29 28 30 29 // Return the key information 31 30 return new Response( 32 31 JSON.stringify({ 33 - did: pubDidKey, 34 - signature: btoa(String.fromCharCode(...sig)), 35 - data: Array.from(data), 32 + keyType: "secp256k1", 33 + publicKeyDid: publicKeyDid, 34 + privateKeyHex: privateKeyHex, 35 + privateKeyMultikey: privateKeyMultikey, 36 36 }), 37 37 { 38 38 headers: { "Content-Type": "application/json" },
+25 -48
routes/api/plc/update.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 2 import { getSessionAgent } from "../../../lib/sessions.ts"; 3 3 import { define } from "../../../utils.ts"; 4 - import * as plc from "@did-plc/lib"; 5 4 6 5 /** 7 - * Update PLC rotation keys for the authenticated user 6 + * Start PLC update process - sends email with token 8 7 */ 9 8 export const handler = define.handlers({ 10 9 async POST(ctx) { 10 + const res = new Response(); 11 11 try { 12 12 const { key: newKey } = await ctx.req.json(); 13 13 ··· 24 24 ); 25 25 } 26 26 27 - const agent = await getSessionAgent(ctx.req); 27 + const agent = await getSessionAgent(ctx.req, res, true); 28 28 if (!agent) { 29 29 return new Response( 30 30 JSON.stringify({ ··· 38 38 ); 39 39 } 40 40 41 - const did = agent.did; 42 - if (!did) { 43 - return new Response( 44 - JSON.stringify({ 45 - success: false, 46 - message: "No DID found in session", 47 - }), 48 - { 49 - status: 400, 50 - headers: { "Content-Type": "application/json" }, 51 - } 52 - ); 53 - } 41 + // Get recommended credentials first 42 + console.log("Getting recommended credentials..."); 43 + const getDidCredentials = 44 + await agent.com.atproto.identity.getRecommendedDidCredentials(); 45 + console.log("Got recommended credentials:", getDidCredentials.data); 54 46 55 - const client = new plc.Client("https://plc.directory"); 56 - 57 - // Fetch current DID document 58 - const didDoc = await client.getDocumentData(did); 59 - if (!didDoc) { 60 - return new Response( 61 - JSON.stringify({ 62 - success: false, 63 - message: "DID document not found", 64 - }), 65 - { 66 - status: 404, 67 - headers: { "Content-Type": "application/json" }, 68 - } 69 - ); 47 + const rotationKeys = getDidCredentials.data.rotationKeys ?? []; 48 + if (!rotationKeys.length) { 49 + throw new Error("No rotation keys provided in recommended credentials"); 70 50 } 71 51 72 - // Create new rotation keys array with the new key at the beginning 73 - const newKeys = [newKey, ...didDoc.rotationKeys]; 74 - 75 - // Create the update operation 76 - const updateOp = plc.updateRotationKeysOp( 77 - did, 78 - didDoc.rotationKeys, 79 - newKeys 80 - ); 81 - 82 - // Submit the operation to the PLC directory 83 - await client.sendOperation(updateOp); 52 + // Request PLC operation token (this will send email) 53 + const plcOp = await agent.com.atproto.identity.signPlcOperation({ 54 + token: "request", // This will trigger email token generation 55 + rotationKeys: [newKey, ...rotationKeys], 56 + ...getDidCredentials.data, 57 + }); 84 58 85 59 return new Response( 86 60 JSON.stringify({ 87 61 success: true, 88 - message: "PLC rotation keys updated successfully", 89 - did, 62 + message: 63 + "Email sent with PLC update token. Please check your email and enter the token to complete the update.", 64 + did: plcOp.data, 90 65 newKey, 91 - totalKeys: newKeys.length, 92 66 }), 93 67 { 94 68 status: 200, 95 - headers: { "Content-Type": "application/json" }, 69 + headers: { 70 + "Content-Type": "application/json", 71 + ...Object.fromEntries(res.headers), // Include session cookie headers 72 + }, 96 73 } 97 74 ); 98 75 } catch (error) { ··· 103 80 return new Response( 104 81 JSON.stringify({ 105 82 success: false, 106 - message: `Failed to update PLC keys: ${message}`, 83 + message: `Failed to start PLC update: ${message}`, 107 84 }), 108 85 { 109 86 status: 500,
+92
routes/api/plc/update/complete.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 3 + import { define } from "../../../../utils.ts"; 4 + 5 + /** 6 + * Complete PLC update using email token 7 + */ 8 + export const handler = define.handlers({ 9 + async POST(ctx) { 10 + const res = new Response(); 11 + try { 12 + const url = new URL(ctx.req.url); 13 + const token = url.searchParams.get("token"); 14 + 15 + if (!token) { 16 + return new Response( 17 + JSON.stringify({ 18 + success: false, 19 + message: "Missing token parameter", 20 + }), 21 + { 22 + status: 400, 23 + headers: { "Content-Type": "application/json" }, 24 + } 25 + ); 26 + } 27 + 28 + const agent = await getSessionAgent(ctx.req, res, true); 29 + if (!agent) { 30 + return new Response( 31 + JSON.stringify({ 32 + success: false, 33 + message: "Unauthorized", 34 + }), 35 + { 36 + status: 401, 37 + headers: { "Content-Type": "application/json" }, 38 + } 39 + ); 40 + } 41 + 42 + const did = agent.did; 43 + if (!did) { 44 + return new Response( 45 + JSON.stringify({ 46 + success: false, 47 + message: "No DID found in session", 48 + }), 49 + { 50 + status: 400, 51 + headers: { "Content-Type": "application/json" }, 52 + } 53 + ); 54 + } 55 + 56 + // Submit the PLC operation with the token 57 + await agent!.com.atproto.identity.submitPlcOperation({ 58 + operation: { token: token }, 59 + }); 60 + 61 + return new Response( 62 + JSON.stringify({ 63 + success: true, 64 + message: "PLC update completed successfully", 65 + did, 66 + }), 67 + { 68 + status: 200, 69 + headers: { 70 + "Content-Type": "application/json", 71 + ...Object.fromEntries(res.headers), // Include session cookie headers 72 + }, 73 + } 74 + ); 75 + } catch (error) { 76 + console.error("PLC update completion error:", error); 77 + const message = 78 + error instanceof Error ? error.message : "Unknown error occurred"; 79 + 80 + return new Response( 81 + JSON.stringify({ 82 + success: false, 83 + message: `Failed to complete PLC update: ${message}`, 84 + }), 85 + { 86 + status: 500, 87 + headers: { "Content-Type": "application/json" }, 88 + } 89 + ); 90 + } 91 + }, 92 + });