handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs
20
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: finish up plc applicator

Mary 64f45017 26d16108

+607 -61
+10 -3
src/api/types/plc.ts
··· 28 28 29 29 const updateOp = v.object({ 30 30 type: v.literal('plc_operation'), 31 - rotationKeys: v.pipe(v.array(didKeyString), v.minLength(1)), 31 + prev: v.nullable(v.string()), 32 + sig: v.string(), 33 + rotationKeys: v.pipe( 34 + v.array(didKeyString), 35 + v.minLength(1), 36 + v.check((v) => new Set(v).size === v.length, `must contain unique keys`), 37 + ), 32 38 verificationMethods: v.record(v.string(), didKeyString), 33 39 alsoKnownAs: v.array(v.pipe(v.string(), v.url())), 34 40 services: v.record( ··· 38 44 endpoint: v.pipe(v.string(), v.url()), 39 45 }), 40 46 ), 41 - prev: v.nullable(v.string()), 42 - sig: v.string(), 43 47 }); 44 48 export type PlcUpdateOp = v.InferOutput<typeof updateOp>; 45 49 ··· 62 66 export type PlcLogEntry = v.InferOutput<typeof plcLogEntry>; 63 67 64 68 export const plcLogEntries = v.array(plcLogEntry); 69 + 70 + export const updatePayload = v.omit(updateOp, ['type', 'prev', 'sig']); 71 + export type PlcUpdatePayload = v.InferOutput<typeof updatePayload>;
+6
src/views/frontpage.tsx
··· 39 39 href: '/plc-oplogs', 40 40 icon: HistoryIcon, 41 41 }, 42 + { 43 + name: `Apply PLC operations`, 44 + description: `Submit operations to your did:plc identity`, 45 + href: `/plc-applicator`, 46 + icon: VisibilityOutlinedIcon, 47 + }, 42 48 ], 43 49 }, 44 50 {
+591 -58
src/views/identity/plc-applicator.tsx
··· 1 1 import { createEffect, createSignal, JSX, Match, Show, Switch } from 'solid-js'; 2 - import { createMutable } from 'solid-js/store'; 2 + import { createMutable, unwrap } from 'solid-js/store'; 3 3 4 + import * as CBOR from '@atcute/cbor'; 4 5 import { AtpSessionData, CredentialManager, XRPC, XRPCError } from '@atcute/client'; 5 6 import { At, ComAtprotoIdentityGetRecommendedDidCredentials } from '@atcute/client/lexicons'; 6 - import * as CBOR from '@atcute/cbor'; 7 7 8 - import { Keypair, verifySignature } from '@atproto/crypto'; 8 + import { P256Keypair, Secp256k1Keypair, verifySignature } from '@atproto/crypto'; 9 9 import * as uint8arrays from 'uint8arrays'; 10 10 11 + import * as v from 'valibot'; 12 + 11 13 import { getDidDocument } from '~/api/queries/did-doc'; 12 14 import { resolveHandleViaAppView } from '~/api/queries/handle'; 13 15 import { getPlcAuditLogs } from '~/api/queries/plc'; 14 16 import { DidDocument, getPdsEndpoint } from '~/api/types/did-doc'; 15 - import { PlcLogEntry } from '~/api/types/plc'; 17 + import { PlcLogEntry, PlcUpdateOp, PlcUpdatePayload, updatePayload } from '~/api/types/plc'; 16 18 import { DID_OR_HANDLE_RE, isDid } from '~/api/utils/strings'; 17 19 18 20 import { assert } from '~/lib/utils/invariant'; 19 21 20 - import { PdsData } from './foo.local'; 21 - 22 - const EMAIL_OTP_RE = /^([a-zA-Z0-9]{5})[- ]?([a-zA-Z0-9]{5})$/; 22 + const EMAIL_OTP_RE = /^([a-zA-Z0-9]{5})[\- ]?([a-zA-Z0-9]{5})$/; 23 23 24 24 const PlcUpdatePage = () => { 25 25 const [step, setStep] = createSignal(1); ··· 33 33 34 34 rotationKeyType?: 'owned' | 'pds'; 35 35 ownedRotationKey?: { 36 - privateKey: Keypair; 36 + keypair: P256Keypair | Secp256k1Keypair; 37 37 didPublicKey: string; 38 38 }; 39 39 pdsData?: { 40 40 service: string; 41 41 session: AtpSessionData; 42 + rpc: XRPC; 42 43 recommendedDidDoc: ComAtprotoIdentityGetRecommendedDidCredentials.Output; 43 44 }; 44 45 accountHasOtp?: boolean; 46 + 47 + prev?: PlcLogEntry; 48 + payload?: PlcUpdatePayload; 45 49 }>({}); 46 50 47 51 return ( 48 52 <fieldset disabled={pending()} class="contents"> 49 53 <div class="p-4"> 50 - <h1 class="text-lg font-bold text-purple-800">PLC operation applicator</h1> 54 + <h1 class="text-lg font-bold text-purple-800">Apply PLC operations</h1> 51 55 <p class="text-gray-600">Submit operations to your did:plc identity</p> 52 56 </div> 53 57 <hr class="mx-4 border-gray-300" /> ··· 176 180 return; 177 181 } 178 182 179 - // if (PdsData) { 180 - // states.pdsData = PdsData as any; 181 - // setStep(3); 182 - // return; 183 - // } 184 - 185 183 assert(states.didDoc); 186 184 187 185 try { ··· 209 207 service, 210 208 session, 211 209 recommendedDidDoc, 210 + rpc, 212 211 }; 213 212 214 - console.log(data); 215 213 states.pdsData = data; 216 214 states.accountHasOtp = false; 217 215 ··· 225 223 return; 226 224 } 227 225 228 - if (err.message.includes('Token is invalid')) { 226 + if (err.kind === 'AuthenticationRequired') { 227 + msg = `Invalid identifier or password`; 228 + } else if (err.kind === 'AccountTakedown') { 229 + msg = `Account has been taken down`; 230 + } else if (err.message.includes('Token is invalid')) { 229 231 msg = `Invalid one-time confirmation code`; 230 232 states.accountHasOtp = true; 231 233 } 232 234 } 233 235 234 - console.error(err); 235 - setError({ step: 2, message: msg ?? `Something went wrong: ${err}` }); 236 + if (msg !== undefined) { 237 + setError({ step: 2, message: msg }); 238 + } else { 239 + console.error(err); 240 + setError({ step: 2, message: `Something went wrong: ${err}` }); 241 + } 236 242 } finally { 237 243 setPending(false); 238 244 } ··· 245 251 <button 246 252 type="button" 247 253 onClick={() => (states.pdsData = undefined)} 254 + hidden={step() !== 2} 248 255 class="text-purple-800 hover:underline disabled:pointer-events-none" 249 256 > 250 257 Sign out? ··· 260 267 type="url" 261 268 name="service" 262 269 required 263 - value={states.didDoc ? getPdsEndpoint(states.didDoc) : ''} 270 + value={(states.didDoc && getPdsEndpoint(states.didDoc)) || ''} 264 271 placeholder="https://bsky.social" 265 272 class="rounded border border-gray-400 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 266 273 /> ··· 285 292 286 293 <Show when={states.accountHasOtp}> 287 294 <label class="mt-6 flex flex-col gap-2"> 288 - <span class="font-semibold text-gray-600">Email one-time confirmation code</span> 295 + <span class="font-semibold text-gray-600">One-time confirmation code</span> 289 296 <input 290 297 type="text" 291 298 name="otp" ··· 299 306 </Show> 300 307 301 308 <p class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 302 - The app runs locally on your browser, your credentials stays within your device. The app is 303 - open source and can be audited as necessary. 309 + This app runs locally on your browser, your credentials stays entirely within your device. It 310 + is open source and can be audited as necessary. 304 311 </p> 305 312 </Show> 306 313 ··· 332 339 step={2} 333 340 title="Enter your private key" 334 341 current={step()} 335 - onSubmit={() => { 336 - // 342 + onSubmit={async (form) => { 343 + try { 344 + setPending(true); 345 + setError(); 346 + 347 + const key = form.get('key') as string; 348 + const type = form.get('type') as 'secp256k1' | 'nistp256'; 349 + 350 + let keypair: P256Keypair | Secp256k1Keypair; 351 + 352 + if (type === 'nistp256') { 353 + keypair = await P256Keypair.import(key); 354 + } else if (type === 'secp256k1') { 355 + keypair = await Secp256k1Keypair.import(key); 356 + } else { 357 + throw new Error(`unsupported '${type}' type`); 358 + } 359 + 360 + states.ownedRotationKey = { didPublicKey: keypair.did(), keypair: keypair }; 361 + 362 + setStep(3); 363 + } catch (err) { 364 + let msg: string | undefined; 365 + 366 + if (msg !== undefined) { 367 + setError({ step: 2, message: msg }); 368 + } else { 369 + console.error(err); 370 + setError({ step: 2, message: `Something went wrong: ${err}` }); 371 + } 372 + } finally { 373 + setPending(false); 374 + } 337 375 }} 338 376 > 339 377 <label class="flex flex-col gap-2"> ··· 346 384 } 347 385 }); 348 386 }} 349 - type="text" 387 + type={step() === 2 ? 'text' : 'password'} 350 388 name="key" 351 389 required 390 + autocomplete="off" 391 + autocorrect="off" 352 392 placeholder="a5973930f9d348..." 353 393 pattern="[0-9a-f]+" 354 - class="rounded border border-gray-400 px-3 py-2 font-mono text-sm tracking-wide placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 394 + class="rounded border border-gray-400 px-3 py-2 font-mono text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 355 395 /> 356 396 </label> 357 - <p class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 358 - The app runs locally on your browser, your private key stays within your device. The app is open 359 - source and can be audited as necessary. 397 + <p hidden={step() !== 2} class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 398 + This app runs locally on your browser, your private key stays entirely within your device. It is 399 + open source and can be audited as necessary. 360 400 </p> 361 401 402 + <fieldset class="mt-6 flex flex-col gap-2"> 403 + <span class="font-semibold text-gray-600">This is a...</span> 404 + 405 + <label class="flex items-start gap-3"> 406 + <input 407 + type="radio" 408 + name="type" 409 + required 410 + value="secp256k1" 411 + class="border-gray-400 text-purple-800 focus:ring-purple-800" 412 + /> 413 + <span class="text-sm">ES256K (secp256k1) private key</span> 414 + </label> 415 + 416 + <label class="flex items-start gap-3"> 417 + <input 418 + type="radio" 419 + name="type" 420 + required 421 + value="nistp256" 422 + class="border-gray-400 text-purple-800 focus:ring-purple-800" 423 + /> 424 + <span class="text-sm">ES256 (nistp256) private key</span> 425 + </label> 426 + </fieldset> 427 + 428 + <ErrorMessageView step={2} error={error()} /> 429 + 362 430 <div hidden={step() !== 2} class="mt-6 flex flex-wrap gap-4"> 363 431 <div class="grow"></div> 364 432 ··· 385 453 step={3} 386 454 title="Select which operation to use as foundation" 387 455 current={step()} 388 - onSubmit={() => { 389 - // 456 + onSubmit={(form) => { 457 + setError(); 458 + 459 + const cid = form.get('cid') as string; 460 + const entry = states.logs?.find((entry) => entry.cid === cid); 461 + 462 + if (!entry) { 463 + setError({ step: 3, message: `Can't find CID ${cid}` }); 464 + return; 465 + } 466 + 467 + const op = entry.operation; 468 + if (op.type !== 'plc_operation' && op.type !== 'create') { 469 + setError({ step: 3, message: `Expected op to be 'plc_operation' or 'create'` }); 470 + return; 471 + } 472 + 473 + states.prev = entry; 474 + states.payload = getPlcPayload(entry); 475 + 476 + setStep(4); 390 477 }} 391 478 > 392 - <label class="mt-6 flex flex-col gap-2"> 479 + <label class="flex flex-col gap-2"> 393 480 <span class="font-semibold text-gray-600">Base operation</span> 394 481 395 482 <select 483 + ref={(node) => { 484 + createEffect(() => { 485 + if (step() === 3) { 486 + setTimeout(() => node.focus(), 1); 487 + } 488 + }); 489 + }} 490 + name="cid" 396 491 value="" 397 492 required 398 - class="rounded border border-gray-400 px-3 py-2 text-sm focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 493 + class="rounded border border-gray-400 py-2 pl-3 pr-8 text-sm focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 399 494 > 400 495 <option value="">Select an operation...</option> 401 496 {(() => { ··· 408 503 409 504 let ownKey: string | undefined; 410 505 if (rotationKeyType === 'pds') { 411 - ownKey = states.pdsData?.recommendedDidDoc.rotationKeys?.[0]; 506 + ownKey = states.pdsData?.recommendedDidDoc.rotationKeys?.at(-1); 412 507 } else if (rotationKeyType === 'owned') { 413 508 ownKey = states.ownedRotationKey?.didPublicKey; 509 + } 510 + 511 + if (ownKey === undefined) { 512 + return []; 414 513 } 415 514 416 515 const length = logs.length; ··· 420 519 421 520 let enabled = signers.includes(ownKey!); 422 521 423 - // If we're showing older operations for nullification, 424 - // check if our key has priority against the signer 522 + // If we're showing older operations for forking/nullification, 523 + // check to see that our key has priority over the signer. 425 524 if (enabled && !last) { 426 - const holderKey = logs[idx + 1].signedBy; 525 + if (rotationKeyType === 'pds') { 526 + // `signPlcOperation` will always grab the last op 527 + enabled = false; 528 + } else { 529 + const holderKey = logs[idx + 1].signedBy; 427 530 428 - const holderIndex = signers.indexOf(holderKey); 429 - const ownIndex = signers.indexOf(ownKey!); 531 + const holderPriority = signers.indexOf(holderKey); 532 + const ownPriority = signers.indexOf(ownKey); 430 533 431 - enabled = ownIndex > holderIndex; 534 + enabled = ownPriority < holderPriority; 535 + } 432 536 } 433 537 434 538 return ( 435 539 <option disabled={!enabled} value={/* @once */ entry.cid}> 436 - {/* @once */ entry.createdAt} 540 + {/* @once */ `${entry.createdAt} (by ${entry.signedBy})`} 437 541 </option> 438 542 ); 439 543 }); ··· 444 548 </label> 445 549 446 550 <p class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 447 - Some operations can't be used as a base if the rotation key is insufficient for nullification, or if 448 - it is not listed. 551 + Some operations can't be used as a base if the rotation key does not have the privilege for 552 + nullification, or if it is not listed. 449 553 </p> 554 + 555 + <ErrorMessageView step={3} error={error()} /> 450 556 451 557 <div hidden={step() !== 3} class="mt-6 flex flex-wrap gap-4"> 452 558 <div class="grow"></div> ··· 472 578 step={4} 473 579 title="Enter your payload" 474 580 current={step()} 475 - onSubmit={() => { 476 - // 581 + onSubmit={(form) => { 582 + setError(); 583 + 584 + const payload = form.get('payload') as string; 585 + 586 + let json: unknown; 587 + try { 588 + json = JSON.parse(payload); 589 + } catch { 590 + setError({ step: 4, message: `Unable to parse JSON` }); 591 + return; 592 + } 593 + 594 + const result = v.safeParse(updatePayload, json); 595 + if (!result.success) { 596 + const issue = result.issues[0]; 597 + const path = v.getDotPath(issue); 598 + 599 + setError({ step: 4, message: `Error at '.${path}'\n${issue.message}` }); 600 + return; 601 + } 602 + 603 + states.payload = json as any; 604 + 605 + setStep(5); 477 606 }} 478 607 > 479 - <div></div> 608 + <label class="flex flex-col gap-2"> 609 + <span class="font-semibold text-gray-600">Payload input</span> 610 + 611 + <textarea 612 + ref={(node) => { 613 + createEffect(() => { 614 + if (step() === 4) { 615 + setTimeout(() => node.focus(), 1); 616 + } 617 + }); 618 + }} 619 + name="payload" 620 + required 621 + rows={22} 622 + value={JSON.stringify(states.payload, null, 2)} 623 + class="resize-y break-all rounded border border-gray-400 px-3 py-2 font-mono text-xs tracking-wider placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 624 + style="field-sizing: content" 625 + /> 626 + </label> 627 + 628 + <div hidden={step() !== 4} class="mt-2 flex flex-wrap gap-4"> 629 + {states.pdsData && ( 630 + <button 631 + type="button" 632 + onClick={() => { 633 + const entry = unwrap(states.prev); 634 + assert(entry !== undefined); 635 + 636 + const recommended = unwrap(states.pdsData!.recommendedDidDoc); 637 + const payload = getPlcPayload(entry); 638 + 639 + if (recommended.alsoKnownAs) { 640 + payload.alsoKnownAs = recommended.alsoKnownAs; 641 + } 642 + if (recommended.rotationKeys) { 643 + payload.rotationKeys = recommended.rotationKeys; 644 + } 645 + if (recommended.services) { 646 + // @ts-expect-error 647 + payload.services = recommended.services; 648 + } 649 + if (recommended.verificationMethods) { 650 + // @ts-expect-error 651 + payload.verificationMethods = recommended.verificationMethods; 652 + } 653 + 654 + states.payload = payload; 655 + }} 656 + class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 657 + > 658 + Use PDS recommendation 659 + </button> 660 + )} 661 + 662 + <button 663 + type="button" 664 + onClick={() => { 665 + const entry = unwrap(states.prev); 666 + assert(entry !== undefined); 667 + 668 + const payload = getPlcPayload(entry); 669 + 670 + states.payload = payload; 671 + }} 672 + class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 673 + > 674 + Reset to default 675 + </button> 676 + </div> 677 + 678 + <ErrorMessageView step={4} error={error()} /> 679 + 680 + <div hidden={step() !== 4} class="mt-6 flex flex-wrap gap-4"> 681 + <div class="grow"></div> 682 + 683 + <button 684 + type="button" 685 + onClick={() => setStep(3)} 686 + class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 687 + > 688 + Previous 689 + </button> 690 + 691 + <button 692 + type="submit" 693 + class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 694 + > 695 + Next 696 + </button> 697 + </div> 480 698 </StepPage> 481 699 482 - <StepPage 483 - step={5} 484 - title="Review" 485 - current={step()} 486 - onSubmit={() => { 487 - // 488 - }} 489 - > 490 - <div></div> 700 + <Switch> 701 + <Match when={states.rotationKeyType === 'pds'}> 702 + <StepPage 703 + step={5} 704 + title="One more step" 705 + current={step()} 706 + onSubmit={async (form) => { 707 + try { 708 + setPending(true); 709 + setError(); 710 + 711 + const code = form.get('code') as string; 712 + 713 + const rpc = states.pdsData!.rpc; 714 + const payload = states.payload!; 715 + 716 + const { data: signage } = await rpc.call('com.atproto.identity.signPlcOperation', { 717 + data: { 718 + token: code, 719 + alsoKnownAs: payload.alsoKnownAs, 720 + rotationKeys: payload.rotationKeys, 721 + services: payload.services, 722 + verificationMethods: payload.verificationMethods, 723 + }, 724 + }); 725 + 726 + await rpc.call('com.atproto.identity.submitPlcOperation', { 727 + data: { 728 + operation: signage.operation, 729 + }, 730 + }); 731 + 732 + setStep(6); 733 + } catch (err) { 734 + let msg: string | undefined; 735 + 736 + if (err instanceof XRPCError) { 737 + if (err.kind === 'InvalidToken' || err.kind === 'ExpiredToken') { 738 + msg = `Confirmation code has expired`; 739 + } 740 + } 741 + 742 + if (msg !== undefined) { 743 + setError({ step: 5, message: msg }); 744 + } else { 745 + console.error(err); 746 + setError({ step: 5, message: `Something went wrong: ${err}` }); 747 + } 748 + } finally { 749 + setPending(false); 750 + } 751 + }} 752 + > 753 + <p> 754 + To continue with this submission, you will need to request a confirmation code from your PDS. 755 + This code will be sent to your account's email address. 756 + </p> 757 + 758 + <label class="mt-6 flex flex-col gap-2"> 759 + <span class="font-semibold text-gray-600">One-time confirmation code</span> 760 + <input 761 + ref={(node) => { 762 + createEffect(() => { 763 + if (step() === 5) { 764 + setTimeout(() => node.focus(), 1); 765 + } 766 + }); 767 + }} 768 + type="text" 769 + name="code" 770 + required 771 + autocomplete="one-time-code" 772 + pattern={/* @once */ EMAIL_OTP_RE.source} 773 + placeholder="AAAAA-BBBBB" 774 + class="rounded border border-gray-400 px-3 py-2 font-mono text-sm tracking-wide placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 775 + /> 776 + </label> 777 + 778 + <div hidden={step() !== 5} class="mt-2 flex flex-wrap gap-4"> 779 + <button 780 + type="button" 781 + onClick={async () => { 782 + try { 783 + const rpc = states.pdsData!.rpc; 784 + 785 + await rpc.call('com.atproto.identity.requestPlcOperationSignature', {}); 786 + alert(`Confirmation code has been sent, check your email inbox.`); 787 + } catch (err) { 788 + let msg: string | undefined; 789 + 790 + if (err instanceof XRPCError) { 791 + if (err.message.includes(`does not have an email address`)) { 792 + msg = `Account does not have an email address`; 793 + } else if (err.message.includes(`not found`)) { 794 + msg = `Account is not registered on the PDS`; 795 + } 796 + } 797 + 798 + if (msg !== undefined) { 799 + setError({ step: 5, message: msg }); 800 + } else { 801 + console.error(err); 802 + setError({ step: 5, message: `Something went wrong: ${err}` }); 803 + } 804 + } 805 + }} 806 + class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 807 + > 808 + Request confirmation code 809 + </button> 810 + </div> 811 + 812 + <p class="mt-6"> 813 + Now, relax. Take a breather. Verify that you have provided the intended payload, and hit{' '} 814 + <i>Submit</i> when you're ready. 815 + </p> 816 + 817 + <p class="mt-3 text-[0.8125rem] font-medium leading-5 text-red-800"> 818 + Caution: This action carries significant risk which can possibly render your did:plc identity 819 + unusable. Proceed at your own risk, we assume no liability for any consequences. 820 + </p> 821 + 822 + <label class="mt-6 flex items-start gap-3"> 823 + <input 824 + type="checkbox" 825 + name="confirm" 826 + required 827 + class="rounded border-gray-400 text-purple-800 focus:ring-purple-800" 828 + /> 829 + <span class="text-sm">I have verified and am ready to proceed</span> 830 + </label> 831 + 832 + <ErrorMessageView step={5} error={error()} /> 833 + 834 + <div hidden={step() !== 5} class="mt-6 flex flex-wrap gap-4"> 835 + <div class="grow"></div> 836 + 837 + <button 838 + type="button" 839 + onClick={() => setStep(4)} 840 + class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 841 + > 842 + Previous 843 + </button> 844 + 845 + <button 846 + type="submit" 847 + class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 848 + > 849 + Submit 850 + </button> 851 + </div> 852 + </StepPage> 853 + </Match> 854 + 855 + <Match when={states.rotationKeyType === 'owned'}> 856 + <StepPage 857 + step={5} 858 + title="One more step" 859 + current={step()} 860 + onSubmit={async () => { 861 + try { 862 + setPending(true); 863 + setError(); 864 + 865 + const keypair = states.ownedRotationKey!.keypair; 866 + const payload = states.payload!; 867 + const prev = states.prev!; 868 + 869 + const operation: Omit<PlcUpdateOp, 'sig'> = { 870 + type: 'plc_operation', 871 + prev: prev!.cid, 872 + 873 + alsoKnownAs: payload.alsoKnownAs, 874 + rotationKeys: payload.rotationKeys, 875 + services: payload.services, 876 + verificationMethods: payload.verificationMethods, 877 + }; 878 + 879 + const opBytes = CBOR.encode(operation); 880 + const sigBytes = await keypair.sign(opBytes); 881 + 882 + const signature = uint8arrays.toString(sigBytes, 'base64url'); 883 + 884 + const signedOperation: PlcUpdateOp = { 885 + ...operation, 886 + sig: signature, 887 + }; 888 + 889 + await pushPlcOperation(states.didDoc!.id, signedOperation); 890 + 891 + setStep(6); 892 + } catch (err) { 893 + let msg: string | undefined; 894 + 895 + if (msg !== undefined) { 896 + setError({ step: 5, message: msg }); 897 + } else { 898 + console.error(err); 899 + setError({ step: 5, message: `Something went wrong: ${err}` }); 900 + } 901 + } finally { 902 + setPending(false); 903 + } 904 + }} 905 + > 906 + <p> 907 + Now, relax. Take a breather. Verify that you have provided the intended payload, and hit{' '} 908 + <i>Submit</i> when you're ready. 909 + </p> 910 + 911 + <p class="mt-3 text-[0.8125rem] font-medium leading-5 text-red-800"> 912 + Caution: This action carries significant risk which can possibly render your did:plc identity 913 + unusable. Proceed at your own risk, we assume no liability for any consequences. 914 + </p> 915 + 916 + <label class="mt-6 flex items-start gap-3"> 917 + <input 918 + ref={(node) => { 919 + createEffect(() => { 920 + if (step() === 5) { 921 + setTimeout(() => node.focus(), 1); 922 + } 923 + }); 924 + }} 925 + type="checkbox" 926 + name="confirm" 927 + required 928 + class="rounded border-gray-400 text-purple-800 focus:ring-purple-800" 929 + /> 930 + <span class="text-sm">I have verified and am ready to proceed</span> 931 + </label> 932 + 933 + <ErrorMessageView step={5} error={error()} /> 934 + 935 + <div hidden={step() !== 5} class="mt-6 flex flex-wrap gap-4"> 936 + <div class="grow"></div> 937 + 938 + <button 939 + type="button" 940 + onClick={() => setStep(4)} 941 + class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 942 + > 943 + Previous 944 + </button> 945 + 946 + <button 947 + type="submit" 948 + class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 949 + > 950 + Submit 951 + </button> 952 + </div> 953 + </StepPage> 954 + </Match> 955 + </Switch> 956 + 957 + <StepPage step={6} title="All done!" current={step()} onSubmit={() => {}}> 958 + <p>Your did:plc identity has been updated.</p> 959 + 960 + <p class="mt-3"> 961 + You can close this page, or reload the page if you intend on doing another submission. 962 + </p> 491 963 </StepPage> 964 + 965 + <div class="pb-24"></div> 492 966 </fieldset> 493 967 ); 494 968 }; 495 969 496 970 export default PlcUpdatePage; 497 971 972 + const pushPlcOperation = async (did: string, operation: PlcUpdateOp) => { 973 + const origin = import.meta.env.VITE_PLC_DIRECTORY_URL; 974 + const response = await fetch(`${origin}/${did}`, { 975 + method: 'post', 976 + headers: { 977 + 'content-type': 'application/json', 978 + }, 979 + body: JSON.stringify(operation), 980 + }); 981 + 982 + const headers = response.headers; 983 + if (!response.ok) { 984 + const type = headers.get('content-type'); 985 + 986 + if (type?.includes('application/json')) { 987 + const json = await response.json(); 988 + if (typeof json === 'object' && json !== null && typeof json.message === 'string') { 989 + throw new Error(json.message); 990 + } 991 + } 992 + 993 + throw new Error(`got http ${response.status} from plc`); 994 + } 995 + }; 996 + 498 997 const formatEmailOtpCode = (code: string) => { 499 998 code = code.toUpperCase(); 500 999 ··· 506 1005 return ''; 507 1006 }; 508 1007 1008 + const getPlcPayload = (entry: PlcLogEntry): PlcUpdatePayload => { 1009 + const op = entry.operation; 1010 + assert(op.type === 'plc_operation' || op.type === 'create'); 1011 + 1012 + if (op.type === 'create') { 1013 + return { 1014 + alsoKnownAs: [`at://${op.handle}`], 1015 + rotationKeys: [op.recoveryKey, op.signingKey], 1016 + verificationMethods: { 1017 + atproto: op.signingKey, 1018 + }, 1019 + services: { 1020 + atproto_pds: { 1021 + type: 'AtprotoPersonalDataServer', 1022 + endpoint: op.service, 1023 + }, 1024 + }, 1025 + }; 1026 + } else if (op.type === 'plc_operation') { 1027 + return { 1028 + alsoKnownAs: op.alsoKnownAs, 1029 + rotationKeys: op.rotationKeys, 1030 + services: op.services, 1031 + verificationMethods: op.verificationMethods, 1032 + }; 1033 + } 1034 + 1035 + assert(false); 1036 + }; 1037 + 509 1038 const getPlcKeying = async (logs: PlcLogEntry[]) => { 510 1039 logs = logs.filter((entry) => !entry.nullified); 511 1040 ··· 518 1047 519 1048 const date = new Date(entry.createdAt); 520 1049 const diff = Date.now() - date.getTime(); 521 - if (idx !== length - 1 && diff / (1000 * 60 * 60) <= 72) { 1050 + if (idx !== length - 1 && diff / (1000 * 60 * 60) > 72) { 522 1051 return; 523 1052 } 524 1053 ··· 597 1126 } 598 1127 })()} 599 1128 > 600 - {(error) => <p class="mt-4 text-[0.8125rem] font-medium leading-5 text-red-800">{error().message}</p>} 1129 + {(error) => ( 1130 + <p class="mt-4 whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800"> 1131 + {error().message} 1132 + </p> 1133 + )} 601 1134 </Show> 602 1135 ); 603 1136 };