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

Configure Feed

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

refactor: separate append/dispute concerns

mary.my.id 3af3c233 b01fa221

verified
+124 -172
+7 -8
src/views/identity/plc-applicator/page.tsx
··· 3 3 import type { CredentialManager } from '@atcute/client'; 4 4 import type { ComAtprotoIdentityGetRecommendedDidCredentials } from '@atcute/client/lexicons'; 5 5 import type { P256PrivateKey, Secp256k1PrivateKey } from '@atcute/crypto'; 6 - import type { DidDocument } from '@atcute/identity'; 6 + import type { CompatibleOperation, IndexedEntry, IndexedEntryWithSigner } from '@atcute/did-plc'; 7 + import type { Did, DidDocument } from '@atcute/identity'; 7 8 8 9 import { UpdatePayload } from '~/api/types/plc'; 9 10 ··· 13 14 14 15 import { Wizard } from '~/components/wizard'; 15 16 16 - import type { DetailedPlcEntry } from './plc-utils'; 17 - 18 17 import Step1_HandleInput from './steps/step1_handle-input'; 19 18 import Step2_PdsAuthentication from './steps/step2_pds-authentication'; 20 19 import Step2_PrivateKeyInput from './steps/step2_private-key-input'; ··· 26 25 27 26 export interface PlcInformation { 28 27 didDoc: DidDocument; 29 - logs: DetailedPlcEntry[]; 28 + logs: IndexedEntryWithSigner[]; 30 29 } 31 30 32 31 export interface PdsSigningMethod { ··· 39 38 export interface PrivateKeySigningMethod { 40 39 type: 'private_key'; 41 40 keypair: Keypair; 42 - didPublicKey: string; 41 + didPublicKey: Did<'key'>; 43 42 } 44 43 45 44 export type SigningMethod = PdsSigningMethod | PrivateKeySigningMethod; ··· 62 61 Step4_PayloadInput: { 63 62 info: PlcInformation; 64 63 method: SigningMethod; 65 - base: DetailedPlcEntry; 64 + base: IndexedEntry<CompatibleOperation>; 66 65 }; 67 66 68 67 Step5_PdsConfirmation: { 69 68 info: PlcInformation; 70 69 method: PdsSigningMethod; 71 - base: DetailedPlcEntry; 70 + base: IndexedEntry<CompatibleOperation>; 72 71 payload: UpdatePayload; 73 72 }; 74 73 Step5_PrivateKeyConfirmation: { 75 74 info: PlcInformation; 76 75 method: PrivateKeySigningMethod; 77 - base: DetailedPlcEntry; 76 + base: IndexedEntry<CompatibleOperation>; 78 77 payload: UpdatePayload; 79 78 }; 80 79
-94
src/views/identity/plc-applicator/plc-utils.ts
··· 1 - import * as CBOR from '@atcute/cbor'; 2 - import { verifySigWithDidKey } from '@atcute/crypto'; 3 1 import type { IndexedEntry } from '@atcute/did-plc'; 4 - import { fromBase64Url } from '@atcute/multibase'; 5 2 6 3 import { UpdatePayload } from '~/api/types/plc'; 7 - import { UnwrapArray } from '~/api/utils/types'; 8 4 9 5 import { assert } from '~/lib/utils/invariant'; 10 6 ··· 37 33 38 34 assert(false); 39 35 }; 40 - 41 - export const getPlcKeying = async (logs: IndexedEntry[]) => { 42 - logs = logs.filter((entry) => !entry.nullified); 43 - 44 - const length = logs.length; 45 - const promises = logs.map(async (entry, idx) => { 46 - const operation = entry.operation; 47 - if (operation.type === 'plc_tombstone') { 48 - return; 49 - } 50 - 51 - // If it's not the last entry, check if the next entry ahead of this one 52 - // was made within the last 72 hours. 53 - if (idx !== length - 1) { 54 - const next = logs[idx + 1]!; 55 - const date = new Date(next.createdAt); 56 - const diff = Date.now() - date.getTime(); 57 - 58 - if (diff / (1_000 * 60 * 60) > 72) { 59 - return; 60 - } 61 - } 62 - 63 - /** keys that potentially signed this operation */ 64 - let signers: `did:key:${string}`[] | undefined; 65 - if (operation.prev === null) { 66 - if (operation.type === 'create') { 67 - signers = [operation.recoveryKey, operation.signingKey]; 68 - } else if (operation.type === 'plc_operation') { 69 - signers = operation.rotationKeys; 70 - } 71 - } else { 72 - const prev = logs[idx - 1]; 73 - assert(prev !== undefined, `missing previous entry from ${entry.createdAt}`); 74 - assert(prev.cid === operation.prev, `prev cid mismatch on ${entry.createdAt}`); 75 - 76 - const prevOp = prev.operation; 77 - 78 - if (prevOp.type === 'create') { 79 - signers = [prevOp.recoveryKey, prevOp.signingKey]; 80 - } else if (prevOp.type === 'plc_operation') { 81 - signers = prevOp.rotationKeys; 82 - } 83 - } 84 - 85 - assert(signers !== undefined, `no signers found for ${entry.createdAt}`); 86 - 87 - const opBytes = CBOR.encode({ ...operation, sig: undefined }); 88 - const sigBytes = fromBase64Url(operation.sig); 89 - 90 - /** key that signed this operation */ 91 - let signedBy: string | undefined; 92 - for (const key of signers) { 93 - const valid = await verifySigWithDidKey(key, sigBytes, opBytes); 94 - if (valid) { 95 - signedBy = key; 96 - break; 97 - } 98 - } 99 - 100 - assert(signedBy !== undefined, `no valid signer for ${entry.createdAt}`); 101 - 102 - return { 103 - ...entry, 104 - signers, 105 - signedBy, 106 - }; 107 - }); 108 - 109 - const fulfilled = await Promise.all(promises); 110 - return fulfilled.filter((entry) => entry !== undefined); 111 - }; 112 - 113 - type DetailedEntries = Awaited<ReturnType<typeof getPlcKeying>>; 114 - export type DetailedPlcEntry = UnwrapArray<DetailedEntries>; 115 - 116 - export const getCurrentSignersFromEntry = (entry: IndexedEntry): string[] => { 117 - const operation = entry.operation; 118 - 119 - /** keys that can sign the next operation */ 120 - let nextSigners: string[] | undefined; 121 - if (operation.type === 'create') { 122 - nextSigners = [operation.recoveryKey, operation.signingKey]; 123 - } else if (operation.type === 'plc_operation') { 124 - nextSigners = operation.rotationKeys; 125 - } 126 - 127 - assert(nextSigners !== undefined, `no signers found for ${entry.createdAt}`); 128 - return nextSigners; 129 - };
+4 -3
src/views/identity/plc-applicator/steps/step1_handle-input.tsx
··· 1 1 import { createSignal } from 'solid-js'; 2 2 3 3 import { XRPCError } from '@atcute/client'; 4 + import { processIndexedEntryLog } from '@atcute/did-plc'; 4 5 import { type Did, isHandle, isPlcDid } from '@atcute/identity'; 5 6 6 7 import { getDidDocument } from '~/api/queries/did-doc'; ··· 16 17 import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 17 18 18 19 import { type PlcInformation, PlcApplicatorConstraints } from '../page'; 19 - import { getPlcKeying } from '../plc-utils'; 20 20 21 21 type Method = 'pds' | 'key'; 22 22 ··· 53 53 } 54 54 55 55 const [didDoc, logs] = await Promise.all([getDidDocument({ did }), getPlcAuditLogs({ did })]); 56 + const { canonical } = await processIndexedEntryLog(did, logs); 56 57 57 58 return { 58 - didDoc, 59 - logs: await getPlcKeying(logs), 59 + didDoc: didDoc, 60 + logs: canonical, 60 61 }; 61 62 }, 62 63 onMutate() {
+113 -67
src/views/identity/plc-applicator/steps/step3_operation-select.tsx
··· 1 - import { createMemo, createSignal } from 'solid-js'; 1 + import { createMemo, createSignal, Show } from 'solid-js'; 2 + 3 + import { 4 + type CompatibleOperation, 5 + type DisputeCandidate, 6 + getDisputeCandidates, 7 + type IndexedEntryWithSigner, 8 + normalizeOp, 9 + } from '@atcute/did-plc'; 10 + import type { Did } from '@atcute/identity'; 2 11 3 12 import Button from '~/components/inputs/button'; 4 13 import SelectInput from '~/components/inputs/select-input'; 5 14 import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 6 15 7 16 import { PlcApplicatorConstraints } from '../page'; 8 - import { getCurrentSignersFromEntry } from '../plc-utils'; 17 + import RadioInput from '~/components/inputs/radio-input'; 9 18 10 19 const Step3_OperationSelect = ({ 11 20 data, ··· 14 23 onNext, 15 24 }: WizardStepProps<PlcApplicatorConstraints, 'Step3_OperationSelect'>) => { 16 25 const [error, setError] = createSignal<string>(); 17 - const [selectedCid, setSelectedCid] = createSignal<string>(); 18 26 19 - const options = createMemo(() => { 20 - const signingMethod = data.method; 21 - const logs = data.info.logs; 27 + const [type, setType] = createSignal<'append' | 'dispute'>(); 28 + const [cid, setCid] = createSignal<string>(); 22 29 23 - let ownKey: string | undefined; 24 - if (signingMethod.type === 'pds') { 25 - ownKey = signingMethod.recommendedDidDoc.rotationKeys?.at(-1); 26 - } else if (signingMethod.type === 'private_key') { 27 - ownKey = signingMethod.didPublicKey; 28 - } 29 - 30 - if (ownKey === undefined) { 31 - return []; 32 - } 33 - 34 - const length = logs.length; 35 - const items = logs.map((entry, idx) => { 36 - const signers = getCurrentSignersFromEntry(entry); 37 - const last = idx === length - 1; 38 - 39 - let enabled = signers.includes(ownKey!); 40 - 41 - // If we're showing older operations for forking/nullification, 42 - // check to see that our key has priority over the signer. 43 - if (enabled && !last) { 44 - if (signingMethod.type === 'pds') { 45 - // `signPlcOperation` will always grab the last op 46 - enabled = false; 47 - } else { 48 - const holderKey = logs[idx + 1].signedBy; 30 + const canAppend = createMemo(() => { 31 + const signing = data.method; 49 32 50 - const holderPriority = signers.indexOf(holderKey); 51 - const ownPriority = signers.indexOf(ownKey); 33 + const lastOp = data.info.logs.at(-1) as IndexedEntryWithSigner<CompatibleOperation>; 34 + const { rotationKeys } = normalizeOp(lastOp.operation); 52 35 53 - enabled = ownPriority < holderPriority; 36 + switch (signing.type) { 37 + case 'pds': { 38 + const key = signing.recommendedDidDoc.rotationKeys?.at(-1) as Did<'key'> | undefined; 39 + if (!key) { 40 + return false; 54 41 } 42 + 43 + return rotationKeys.includes(key); 55 44 } 45 + case 'private_key': { 46 + return rotationKeys.includes(signing.didPublicKey); 47 + } 48 + } 49 + }); 56 50 57 - return { 58 - value: entry.cid, 59 - label: `${entry.createdAt} (by ${entry.signedBy})`, 60 - disabled: !enabled, 61 - }; 62 - }); 51 + const disputes = createMemo((): DisputeCandidate[] => { 52 + const signing = data.method; 63 53 64 - return items.reverse(); 54 + switch (signing.type) { 55 + case 'pds': { 56 + // signPlcOperation always grabs the last operation, so we can't make 57 + // any dispute attempts. 58 + return []; 59 + } 60 + case 'private_key': { 61 + return getDisputeCandidates(data.info.logs, signing.didPublicKey); 62 + } 63 + } 65 64 }); 66 65 67 66 return ( 68 67 <Stage 69 - title="Select which operation to use as foundation" 68 + title="What do you want to do?" 70 69 onSubmit={() => { 71 70 setError(); 72 71 73 - const cid = selectedCid(); 74 - const entry = data.info.logs.find((entry) => entry.cid === cid); 72 + const $type = type(); 73 + const $cid = cid(); 75 74 76 - if (!entry) { 77 - setError(`Can't find CID ${cid}`); 78 - return; 79 - } 75 + switch ($type) { 76 + case 'append': { 77 + const lastOp = data.info.logs.at(-1) as IndexedEntryWithSigner<CompatibleOperation>; 78 + 79 + onNext('Step4_PayloadInput', { 80 + info: data.info, 81 + method: data.method, 82 + base: lastOp, 83 + }); 84 + 85 + break; 86 + } 87 + case 'dispute': { 88 + const entry = disputes().find((entry) => entry.base.cid === $cid); 89 + if (!entry) { 90 + setError(`Can't find dispute entry for ${$cid}`); 91 + return; 92 + } 93 + 94 + onNext('Step4_PayloadInput', { 95 + info: data.info, 96 + method: data.method, 97 + base: entry.base, 98 + }); 80 99 81 - const operation = entry.operation; 82 - if (operation.type !== 'plc_operation' && operation.type === 'create') { 83 - setError(`Expected operation to be of type "plc_operation" or "create"`); 84 - return; 100 + break; 101 + } 85 102 } 86 - 87 - onNext('Step4_PayloadInput', { 88 - info: data.info, 89 - method: data.method, 90 - base: entry, 91 - }); 92 103 }} 93 104 > 94 - <SelectInput 95 - label="Base operation" 96 - blurb="Some operations can't be used as a base if the rotation key does not have the privilege for nullification, or if it is not listed." 105 + <RadioInput 106 + label="I want to..." 97 107 required 98 - value={selectedCid()} 99 - autofocus={isActive()} 100 - options={[{ value: '', label: `Select an operation...` }, ...options()]} 101 - onChange={setSelectedCid} 108 + value={type()} 109 + options={[ 110 + { 111 + value: 'append', 112 + label: `Append an operation`, 113 + disabled: !canAppend(), 114 + }, 115 + { 116 + value: 'dispute', 117 + label: `Dispute an existing operation`, 118 + disabled: disputes().length === 0, 119 + }, 120 + ]} 121 + onChange={setType} 102 122 /> 103 123 124 + <Show when={type() === 'dispute'}> 125 + <SelectInput 126 + label="Dispute operation" 127 + blurb="Select an operation to dispute." 128 + required 129 + value={cid()} 130 + autofocus={isActive()} 131 + options={[ 132 + { value: '', label: `Select an operation...` }, 133 + ...disputes().map((entry) => ({ 134 + value: entry.base.cid, 135 + label: `${entry.base.cid} ➔ ${entry.disputed.cid} (by ${entry.disputed.signedBy})`, 136 + })), 137 + ]} 138 + onChange={setCid} 139 + /> 140 + </Show> 141 + 142 + <Show when={!canAppend() && disputes().length === 0}> 143 + <p class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800"> 144 + This rotation key can't be used. 145 + </p> 146 + </Show> 147 + 104 148 <StageErrorView error={error()} /> 105 149 106 150 <StageActions hidden={!isActive()}> ··· 108 152 <Button variant="secondary" onClick={onPrevious}> 109 153 Previous 110 154 </Button> 111 - <Button type="submit">Next</Button> 155 + <Button type="submit" disabled={type() === undefined}> 156 + Next 157 + </Button> 112 158 </StageActions> 113 159 </Stage> 114 160 );