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: parts of the plc applicator tool

Mary 55bab911 ea320f37

+706
+2
package.json
··· 12 12 "@atcute/cbor": "^1.0.5", 13 13 "@atcute/cid": "^1.0.1", 14 14 "@atcute/client": "^2.0.3", 15 + "@atproto/crypto": "^0.4.2", 15 16 "@mary/events": "npm:@jsr/mary__events@^0.1.0", 16 17 "@mary/solid-freeze": "npm:@externdefs/solid-freeze@^0.1.1", 17 18 "@mary/tar": "npm:@jsr/mary__tar@^0.2.4", 18 19 "nanoid": "^5.0.8", 19 20 "native-file-system-adapter": "^3.0.1", 20 21 "solid-js": "^1.9.2", 22 + "uint8arrays": "^5.1.0", 21 23 "valibot": "1.0.0-beta.2" 22 24 }, 23 25 "devDependencies": {
+59
pnpm-lock.yaml
··· 23 23 '@atcute/client': 24 24 specifier: ^2.0.3 25 25 version: 2.0.3 26 + '@atproto/crypto': 27 + specifier: ^0.4.2 28 + version: 0.4.2 26 29 '@mary/events': 27 30 specifier: npm:@jsr/mary__events@^0.1.0 28 31 version: '@jsr/mary__events@0.1.0' ··· 41 44 solid-js: 42 45 specifier: ^1.9.2 43 46 version: 1.9.3 47 + uint8arrays: 48 + specifier: ^5.1.0 49 + version: 5.1.0 44 50 valibot: 45 51 specifier: 1.0.0-beta.2 46 52 version: 1.0.0-beta.2(typescript@5.7.0-beta) ··· 111 117 112 118 '@atcute/varint@1.0.0': 113 119 resolution: {integrity: sha512-NEBOGkdaDY8cjlDg49kefIsRM7iv/4oReEnOr3bN4tF3IxBGdc6Io1NCJz1xNBNdUL+3VDG3CKHiRji91HXaTg==} 120 + 121 + '@atproto/crypto@0.4.2': 122 + resolution: {integrity: sha512-aeOfPQYCDbhn2hV06oBF2KXrWjf/BK4yL8lfANJKSmKl3tKWCkiW/moi643rUXXxSE72KtWtQeqvNFYnnFJ0ig==} 114 123 115 124 '@babel/code-frame@7.26.0': 116 125 resolution: {integrity: sha512-INCKxTtbXtcNbUZ3YXutwMpEleqttcswhAdee7dhuoVrD2cnuc3PqtERBtxkX5nziX9vnBL8WXmSGwv8CuPV6g==} ··· 554 563 '@jsr/mary__tar@0.2.4': 555 564 resolution: {integrity: sha512-jFjPcZj8DRSukPLZOt6+h74cVFdfdTMG9gzbW67YByCJTD52PEpe2sNcfCSw4mQ8hZBNgwiufCPyYL8hR9yicA==, tarball: https://npm.jsr.io/~/11/@jsr/mary__tar/0.2.4.tgz} 556 565 566 + '@noble/curves@1.7.0': 567 + resolution: {integrity: sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==} 568 + engines: {node: ^14.21.3 || >=16} 569 + 570 + '@noble/hashes@1.6.0': 571 + resolution: {integrity: sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==} 572 + engines: {node: ^14.21.3 || >=16} 573 + 574 + '@noble/hashes@1.6.1': 575 + resolution: {integrity: sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==} 576 + engines: {node: ^14.21.3 || >=16} 577 + 557 578 '@nodelib/fs.scandir@2.1.5': 558 579 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} 559 580 engines: {node: '>= 8'} ··· 1052 1073 ms@2.1.3: 1053 1074 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1054 1075 1076 + multiformats@13.3.1: 1077 + resolution: {integrity: sha512-QxowxTNwJ3r5RMctoGA5p13w5RbRT2QDkoM+yFlqfLiioBp78nhDjnRLvmSBI9+KAqN4VdgOVWM9c0CHd86m3g==} 1078 + 1079 + multiformats@9.9.0: 1080 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1081 + 1055 1082 mustache@4.2.0: 1056 1083 resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} 1057 1084 hasBin: true ··· 1405 1432 ufo@1.5.4: 1406 1433 resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} 1407 1434 1435 + uint8arrays@3.0.0: 1436 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 1437 + 1438 + uint8arrays@5.1.0: 1439 + resolution: {integrity: sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==} 1440 + 1408 1441 undici-types@6.19.8: 1409 1442 resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} 1410 1443 ··· 1579 1612 '@atcute/client@2.0.3': {} 1580 1613 1581 1614 '@atcute/varint@1.0.0': {} 1615 + 1616 + '@atproto/crypto@0.4.2': 1617 + dependencies: 1618 + '@noble/curves': 1.7.0 1619 + '@noble/hashes': 1.6.1 1620 + uint8arrays: 3.0.0 1582 1621 1583 1622 '@babel/code-frame@7.26.0': 1584 1623 dependencies: ··· 1908 1947 1909 1948 '@jsr/mary__tar@0.2.4': {} 1910 1949 1950 + '@noble/curves@1.7.0': 1951 + dependencies: 1952 + '@noble/hashes': 1.6.0 1953 + 1954 + '@noble/hashes@1.6.0': {} 1955 + 1956 + '@noble/hashes@1.6.1': {} 1957 + 1911 1958 '@nodelib/fs.scandir@2.1.5': 1912 1959 dependencies: 1913 1960 '@nodelib/fs.stat': 2.0.5 ··· 2384 2431 2385 2432 ms@2.1.3: {} 2386 2433 2434 + multiformats@13.3.1: {} 2435 + 2436 + multiformats@9.9.0: {} 2437 + 2387 2438 mustache@4.2.0: {} 2388 2439 2389 2440 mz@2.7.0: ··· 2690 2741 typescript@5.7.0-beta: {} 2691 2742 2692 2743 ufo@1.5.4: {} 2744 + 2745 + uint8arrays@3.0.0: 2746 + dependencies: 2747 + multiformats: 9.9.0 2748 + 2749 + uint8arrays@5.1.0: 2750 + dependencies: 2751 + multiformats: 13.3.1 2693 2752 2694 2753 undici-types@6.19.8: {} 2695 2754
+4
src/routes.ts
··· 21 21 path: '/plc-oplogs', 22 22 component: lazy(() => import('./views/identity/plc-oplogs')), 23 23 }, 24 + { 25 + path: '/plc-applicator', 26 + component: lazy(() => import('./views/identity/plc-applicator')), 27 + }, 24 28 25 29 { 26 30 path: '/repo-export',
+641
src/views/identity/plc-applicator.tsx
··· 1 + import { createEffect, createSignal, JSX, Match, Show, Switch } from 'solid-js'; 2 + import { createMutable } from 'solid-js/store'; 3 + 4 + import { AtpSessionData, CredentialManager, XRPC, XRPCError } from '@atcute/client'; 5 + import { At, ComAtprotoIdentityGetRecommendedDidCredentials } from '@atcute/client/lexicons'; 6 + import * as CBOR from '@atcute/cbor'; 7 + 8 + import { Keypair, verifySignature } from '@atproto/crypto'; 9 + import * as uint8arrays from 'uint8arrays'; 10 + 11 + import { getDidDocument } from '~/api/queries/did-doc'; 12 + import { resolveHandleViaAppView } from '~/api/queries/handle'; 13 + import { getPlcAuditLogs } from '~/api/queries/plc'; 14 + import { DidDocument, getPdsEndpoint } from '~/api/types/did-doc'; 15 + import { PlcLogEntry } from '~/api/types/plc'; 16 + import { DID_OR_HANDLE_RE, isDid } from '~/api/utils/strings'; 17 + 18 + import { assert } from '~/lib/utils/invariant'; 19 + 20 + import { PdsData } from './foo.local'; 21 + 22 + const EMAIL_OTP_RE = /^([a-zA-Z0-9]{5})[- ]?([a-zA-Z0-9]{5})$/; 23 + 24 + const PlcUpdatePage = () => { 25 + const [step, setStep] = createSignal(1); 26 + const [pending, setPending] = createSignal(false); 27 + 28 + const [error, setError] = createSignal<{ step: number; message: string }>(); 29 + 30 + const states = createMutable<{ 31 + didDoc?: DidDocument; 32 + logs?: Awaited<ReturnType<typeof getPlcKeying>>; 33 + 34 + rotationKeyType?: 'owned' | 'pds'; 35 + ownedRotationKey?: { 36 + privateKey: Keypair; 37 + didPublicKey: string; 38 + }; 39 + pdsData?: { 40 + service: string; 41 + session: AtpSessionData; 42 + recommendedDidDoc: ComAtprotoIdentityGetRecommendedDidCredentials.Output; 43 + }; 44 + accountHasOtp?: boolean; 45 + }>({}); 46 + 47 + return ( 48 + <fieldset disabled={pending()} class="contents"> 49 + <div class="p-4"> 50 + <h1 class="text-lg font-bold text-purple-800">PLC operation applicator</h1> 51 + <p class="text-gray-600">Submit operations to your did:plc identity</p> 52 + </div> 53 + <hr class="mx-4 border-gray-300" /> 54 + 55 + <StepPage 56 + step={1} 57 + title="Enter the did:plc identity you want to edit" 58 + current={step()} 59 + onSubmit={async (form) => { 60 + try { 61 + setPending(true); 62 + setError(); 63 + 64 + const identifier = form.get('ident') as string; 65 + const rotation = form.get('rotation') as 'owned' | 'pds'; 66 + 67 + let did: At.DID; 68 + if (isDid(identifier)) { 69 + did = identifier; 70 + } else { 71 + did = await resolveHandleViaAppView({ handle: identifier }); 72 + } 73 + 74 + if (!did.startsWith('did:plc:')) { 75 + setError({ step: 1, message: `"${did}" is not did:plc` }); 76 + return; 77 + } 78 + 79 + const [didDoc, logs] = await Promise.all([getDidDocument({ did }), getPlcAuditLogs({ did })]); 80 + 81 + states.didDoc = didDoc; 82 + states.logs = await getPlcKeying(logs); 83 + 84 + states.rotationKeyType = rotation; 85 + 86 + if (rotation === 'owned') { 87 + states.pdsData = undefined; 88 + } else if (rotation === 'pds') { 89 + states.ownedRotationKey = undefined; 90 + } 91 + 92 + if (states.pdsData) { 93 + if (states.pdsData.session.did !== did) { 94 + states.pdsData = undefined; 95 + states.accountHasOtp = false; 96 + } 97 + } 98 + 99 + setStep(2); 100 + } catch (err) { 101 + console.error(err); 102 + setError({ step: 1, message: `Something went wrong: ${err}` }); 103 + } finally { 104 + setPending(false); 105 + } 106 + }} 107 + > 108 + <label class="flex flex-col gap-2"> 109 + <span class="font-semibold text-gray-600">Handle or DID identifier</span> 110 + <input 111 + ref={(node) => { 112 + createEffect(() => { 113 + if (step() === 1) { 114 + setTimeout(() => node.focus(), 1); 115 + } 116 + }); 117 + }} 118 + type="text" 119 + name="ident" 120 + required 121 + pattern={/* @once */ DID_OR_HANDLE_RE.source} 122 + placeholder="paul.bsky.social" 123 + 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" 124 + /> 125 + </label> 126 + 127 + <fieldset class="mt-6 flex flex-col gap-2"> 128 + <span class="font-semibold text-gray-600">I will be using...</span> 129 + 130 + <label class="flex items-center gap-3"> 131 + <input 132 + type="radio" 133 + name="rotation" 134 + required 135 + value="pds" 136 + class="border-gray-400 text-purple-800 focus:ring-purple-800" 137 + /> 138 + <span class="text-sm">my PDS' rotation key (requires sign in)</span> 139 + </label> 140 + 141 + <label class="flex items-center gap-3"> 142 + <input 143 + type="radio" 144 + name="rotation" 145 + required 146 + value="owned" 147 + class="border-gray-400 text-purple-800 focus:ring-purple-800" 148 + /> 149 + <span class="text-sm">my own rotation key</span> 150 + </label> 151 + </fieldset> 152 + 153 + <ErrorMessageView step={1} error={error()} /> 154 + 155 + <div hidden={step() !== 1} class="mt-6 flex flex-wrap gap-4"> 156 + <div class="grow"></div> 157 + 158 + <button 159 + type="submit" 160 + 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" 161 + > 162 + Next 163 + </button> 164 + </div> 165 + </StepPage> 166 + 167 + <Switch> 168 + <Match when={states.rotationKeyType === 'pds'}> 169 + <StepPage 170 + step={2} 171 + title="Sign in to your PDS" 172 + current={step()} 173 + onSubmit={async (form) => { 174 + if (states.pdsData) { 175 + setStep(3); 176 + return; 177 + } 178 + 179 + // if (PdsData) { 180 + // states.pdsData = PdsData as any; 181 + // setStep(3); 182 + // return; 183 + // } 184 + 185 + assert(states.didDoc); 186 + 187 + try { 188 + setPending(true); 189 + setError(); 190 + 191 + const service = form.get('service') as string; 192 + const pass = form.get('pass') as string; 193 + const otp = form.get('otp') as string | null; 194 + 195 + const manager = new CredentialManager({ service }); 196 + const session = await manager.login({ 197 + identifier: states.didDoc.id, 198 + password: pass, 199 + code: otp ? formatEmailOtpCode(otp) : undefined, 200 + }); 201 + 202 + const rpc = new XRPC({ handler: manager }); 203 + const { data: recommendedDidDoc } = await rpc.get( 204 + 'com.atproto.identity.getRecommendedDidCredentials', 205 + {}, 206 + ); 207 + 208 + const data = { 209 + service, 210 + session, 211 + recommendedDidDoc, 212 + }; 213 + 214 + console.log(data); 215 + states.pdsData = data; 216 + states.accountHasOtp = false; 217 + 218 + setStep(3); 219 + } catch (err) { 220 + let msg: string | undefined; 221 + 222 + if (err instanceof XRPCError) { 223 + if (err.kind === 'AuthFactorTokenRequired') { 224 + states.accountHasOtp = true; 225 + return; 226 + } 227 + 228 + if (err.message.includes('Token is invalid')) { 229 + msg = `Invalid one-time confirmation code`; 230 + states.accountHasOtp = true; 231 + } 232 + } 233 + 234 + console.error(err); 235 + setError({ step: 2, message: msg ?? `Something went wrong: ${err}` }); 236 + } finally { 237 + setPending(false); 238 + } 239 + }} 240 + > 241 + <Show when={states.pdsData}> 242 + {(session) => ( 243 + <p class="break-words"> 244 + Signed in via <b>{session().service}</b>.{' '} 245 + <button 246 + type="button" 247 + onClick={() => (states.pdsData = undefined)} 248 + class="text-purple-800 hover:underline disabled:pointer-events-none" 249 + > 250 + Sign out? 251 + </button> 252 + </p> 253 + )} 254 + </Show> 255 + 256 + <Show when={!states.pdsData}> 257 + <label class="flex flex-col gap-2"> 258 + <span class="font-semibold text-gray-600">PDS service</span> 259 + <input 260 + type="url" 261 + name="service" 262 + required 263 + value={states.didDoc ? getPdsEndpoint(states.didDoc) : ''} 264 + placeholder="https://bsky.social" 265 + 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 + /> 267 + </label> 268 + 269 + <label class="mt-6 flex flex-col gap-2"> 270 + <span class="font-semibold text-gray-600">Main password</span> 271 + <input 272 + ref={(node) => { 273 + createEffect(() => { 274 + if (step() === 2) { 275 + setTimeout(() => node.focus(), 1); 276 + } 277 + }); 278 + }} 279 + type="password" 280 + name="pass" 281 + required 282 + 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" 283 + /> 284 + </label> 285 + 286 + <Show when={states.accountHasOtp}> 287 + <label class="mt-6 flex flex-col gap-2"> 288 + <span class="font-semibold text-gray-600">Email one-time confirmation code</span> 289 + <input 290 + type="text" 291 + name="otp" 292 + required 293 + autocomplete="one-time-code" 294 + pattern={/* @once */ EMAIL_OTP_RE.source} 295 + placeholder="AAAAA-BBBBB" 296 + 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" 297 + /> 298 + </label> 299 + </Show> 300 + 301 + <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. 304 + </p> 305 + </Show> 306 + 307 + <ErrorMessageView step={2} error={error()} /> 308 + 309 + <div hidden={step() !== 2} class="mt-6 flex flex-wrap gap-4"> 310 + <div class="grow"></div> 311 + 312 + <button 313 + type="button" 314 + onClick={() => setStep(1)} 315 + 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" 316 + > 317 + Previous 318 + </button> 319 + 320 + <button 321 + type="submit" 322 + 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" 323 + > 324 + Next 325 + </button> 326 + </div> 327 + </StepPage> 328 + </Match> 329 + 330 + <Match when={states.rotationKeyType === 'owned'}> 331 + <StepPage 332 + step={2} 333 + title="Enter your private key" 334 + current={step()} 335 + onSubmit={() => { 336 + // 337 + }} 338 + > 339 + <label class="flex flex-col gap-2"> 340 + <span class="font-semibold text-gray-600">Hex-encoded private key</span> 341 + <input 342 + ref={(node) => { 343 + createEffect(() => { 344 + if (step() === 2) { 345 + setTimeout(() => node.focus(), 1); 346 + } 347 + }); 348 + }} 349 + type="text" 350 + name="key" 351 + required 352 + placeholder="a5973930f9d348..." 353 + 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" 355 + /> 356 + </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. 360 + </p> 361 + 362 + <div hidden={step() !== 2} class="mt-6 flex flex-wrap gap-4"> 363 + <div class="grow"></div> 364 + 365 + <button 366 + type="button" 367 + onClick={() => setStep(1)} 368 + 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" 369 + > 370 + Previous 371 + </button> 372 + 373 + <button 374 + type="submit" 375 + 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" 376 + > 377 + Next 378 + </button> 379 + </div> 380 + </StepPage> 381 + </Match> 382 + </Switch> 383 + 384 + <StepPage 385 + step={3} 386 + title="Select which operation to use as foundation" 387 + current={step()} 388 + onSubmit={() => { 389 + // 390 + }} 391 + > 392 + <label class="mt-6 flex flex-col gap-2"> 393 + <span class="font-semibold text-gray-600">Base operation</span> 394 + 395 + <select 396 + value="" 397 + 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" 399 + > 400 + <option value="">Select an operation...</option> 401 + {(() => { 402 + const logs = states.logs; 403 + if (!logs) { 404 + return null; 405 + } 406 + 407 + const rotationKeyType = states.rotationKeyType; 408 + 409 + let ownKey: string | undefined; 410 + if (rotationKeyType === 'pds') { 411 + ownKey = states.pdsData?.recommendedDidDoc.rotationKeys?.[0]; 412 + } else if (rotationKeyType === 'owned') { 413 + ownKey = states.ownedRotationKey?.didPublicKey; 414 + } 415 + 416 + const length = logs.length; 417 + const nodes = logs.map((entry, idx) => { 418 + const signers = getCurrentSignersFromEntry(entry); 419 + const last = idx === length - 1; 420 + 421 + let enabled = signers.includes(ownKey!); 422 + 423 + // If we're showing older operations for nullification, 424 + // check if our key has priority against the signer 425 + if (enabled && !last) { 426 + const holderKey = logs[idx + 1].signedBy; 427 + 428 + const holderIndex = signers.indexOf(holderKey); 429 + const ownIndex = signers.indexOf(ownKey!); 430 + 431 + enabled = ownIndex > holderIndex; 432 + } 433 + 434 + return ( 435 + <option disabled={!enabled} value={/* @once */ entry.cid}> 436 + {/* @once */ entry.createdAt} 437 + </option> 438 + ); 439 + }); 440 + 441 + return nodes.reverse(); 442 + })()} 443 + </select> 444 + </label> 445 + 446 + <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. 449 + </p> 450 + 451 + <div hidden={step() !== 3} class="mt-6 flex flex-wrap gap-4"> 452 + <div class="grow"></div> 453 + 454 + <button 455 + type="button" 456 + onClick={() => setStep(2)} 457 + 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" 458 + > 459 + Previous 460 + </button> 461 + 462 + <button 463 + type="submit" 464 + 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" 465 + > 466 + Next 467 + </button> 468 + </div> 469 + </StepPage> 470 + 471 + <StepPage 472 + step={4} 473 + title="Enter your payload" 474 + current={step()} 475 + onSubmit={() => { 476 + // 477 + }} 478 + > 479 + <div></div> 480 + </StepPage> 481 + 482 + <StepPage 483 + step={5} 484 + title="Review" 485 + current={step()} 486 + onSubmit={() => { 487 + // 488 + }} 489 + > 490 + <div></div> 491 + </StepPage> 492 + </fieldset> 493 + ); 494 + }; 495 + 496 + export default PlcUpdatePage; 497 + 498 + const formatEmailOtpCode = (code: string) => { 499 + code = code.toUpperCase(); 500 + 501 + const match = EMAIL_OTP_RE.exec(code); 502 + if (match !== null) { 503 + return `${match[1]}-${match[2]}`; 504 + } 505 + 506 + return ''; 507 + }; 508 + 509 + const getPlcKeying = async (logs: PlcLogEntry[]) => { 510 + logs = logs.filter((entry) => !entry.nullified); 511 + 512 + const length = logs.length; 513 + const promises = logs.map(async (entry, idx) => { 514 + const operation = entry.operation; 515 + if (operation.type === 'plc_tombstone') { 516 + return; 517 + } 518 + 519 + const date = new Date(entry.createdAt); 520 + const diff = Date.now() - date.getTime(); 521 + if (idx !== length - 1 && diff / (1000 * 60 * 60) <= 72) { 522 + return; 523 + } 524 + 525 + /** keys that potentially signed this operation */ 526 + let signers: string[] | undefined; 527 + if (operation.prev === null) { 528 + if (operation.type === 'create') { 529 + signers = [operation.recoveryKey, operation.signingKey]; 530 + } else if (operation.type === 'plc_operation') { 531 + signers = operation.rotationKeys; 532 + } 533 + } else { 534 + const prev = logs[idx - 1]; 535 + assert(prev !== undefined, `missing previous entry from ${entry.createdAt}`); 536 + assert(prev.cid === operation.prev, `prev cid mismatch on ${entry.createdAt}`); 537 + 538 + const prevOp = prev.operation; 539 + 540 + if (prevOp.type === 'create') { 541 + signers = [prevOp.recoveryKey, prevOp.signingKey]; 542 + } else if (prevOp.type === 'plc_operation') { 543 + signers = prevOp.rotationKeys; 544 + } 545 + } 546 + 547 + assert(signers !== undefined, `no signers found for ${entry.createdAt}`); 548 + 549 + const opBytes = CBOR.encode({ ...operation, sig: undefined }); 550 + const sigBytes = uint8arrays.fromString(operation.sig, 'base64url'); 551 + 552 + /** key that signed this operation */ 553 + let signedBy: string | undefined; 554 + for (const key of signers) { 555 + const valid = await verifySignature(key, opBytes, sigBytes); 556 + if (valid) { 557 + signedBy = key; 558 + break; 559 + } 560 + } 561 + 562 + assert(signedBy !== undefined, `no valid signer for ${entry.createdAt}`); 563 + 564 + return { 565 + ...entry, 566 + signers, 567 + signedBy, 568 + }; 569 + }); 570 + 571 + const fulfilled = await Promise.all(promises); 572 + return fulfilled.filter((entry) => entry !== undefined); 573 + }; 574 + 575 + const getCurrentSignersFromEntry = (entry: PlcLogEntry): string[] => { 576 + const operation = entry.operation; 577 + 578 + /** keys that can sign the next operation */ 579 + let nextSigners: string[] | undefined; 580 + if (operation.type === 'create') { 581 + nextSigners = [operation.recoveryKey, operation.signingKey]; 582 + } else if (operation.type === 'plc_operation') { 583 + nextSigners = operation.rotationKeys; 584 + } 585 + 586 + assert(nextSigners !== undefined, `no signers found for ${entry.createdAt}`); 587 + return nextSigners; 588 + }; 589 + 590 + const ErrorMessageView = (props: { step: number; error: { step: number; message: string } | undefined }) => { 591 + return ( 592 + <Show 593 + when={(() => { 594 + const error = props.error; 595 + if (error && props.step === error.step) { 596 + return error; 597 + } 598 + })()} 599 + > 600 + {(error) => <p class="mt-4 text-[0.8125rem] font-medium leading-5 text-red-800">{error().message}</p>} 601 + </Show> 602 + ); 603 + }; 604 + 605 + const StepPage = (props: { 606 + step: number; 607 + title: string; 608 + current: number; 609 + onSubmit: (formData: FormData) => void; 610 + children: JSX.Element; 611 + }) => { 612 + const onSubmit = props.onSubmit; 613 + 614 + const handleSubmit: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (ev) => { 615 + ev.preventDefault(); 616 + 617 + const formData = new FormData(ev.currentTarget); 618 + onSubmit(formData); 619 + }; 620 + 621 + return ( 622 + <Show when={props.step <= props.current}> 623 + <form onSubmit={handleSubmit} class="contents"> 624 + <fieldset disabled={props.step !== props.current} class="flex min-w-0 gap-4 px-4 disabled:opacity-50"> 625 + <div class="flex flex-col items-center gap-1 pt-4"> 626 + <div class="grid h-6 w-6 place-items-center rounded-full bg-gray-200 py-1 text-center text-sm font-medium leading-none text-black"> 627 + {'' + props.step} 628 + </div> 629 + 630 + <div hidden={!(props.current > props.step)} class="-mb-3 grow border-l border-gray-400"></div> 631 + </div> 632 + 633 + <div class="min-w-0 grow py-4"> 634 + <h3 class="mb-[1.125rem] mt-0.5 text-sm font-semibold">{props.title}</h3> 635 + {props.children} 636 + </div> 637 + </fieldset> 638 + </form> 639 + </Show> 640 + ); 641 + };