Suite of AT Protocol TypeScript libraries built on web standards
20
fork

Configure Feed

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

fix: circular dependency

+375 -41
+277 -14
lex/resolver/lex-resolver.ts
··· 1 - import type { Cid } from "../data/mod.ts"; 1 + import * as crypto from "@atp/crypto"; 2 2 import { resolveTxt as resolveTxtWithNode } from "node:dns/promises"; 3 3 import { type AtprotoData, type DidCache, DidResolver } from "@atp/identity"; 4 4 import { ··· 9 9 } from "../external.ts"; 10 10 import * as l from "../external.ts"; 11 11 import { 12 + decode as decodeCbor, 13 + encode as encodeCbor, 14 + parseCidFromBytes, 15 + verifyCidForBytes, 16 + } from "../cbor/mod.ts"; 17 + import { asCid, type Cid } from "../data/mod.ts"; 18 + import { 12 19 type LexiconDocument, 13 20 lexiconDocumentSchema, 14 21 } from "../document/mod.ts"; 15 - import { 16 - def as repoDef, 17 - MemoryBlockstore, 18 - MST, 19 - readCarWithRoot, 20 - verifyCommitSig, 21 - } from "@atp/repo"; 22 22 import { AtUri, ensureValidDid, NSID } from "@atp/syntax"; 23 23 import { LexResolverError } from "./lex-resolver-error.ts"; 24 24 ··· 417 417 rkey: string, 418 418 ): Promise<LexResolverFetchResult> { 419 419 const { root, blocks } = await readCarWithRoot(car); 420 - const blockstore = new MemoryBlockstore(blocks); 421 - 422 - const commit = blockstore.readObj(root, repoDef.commit); 420 + const commit = readCommitBlock(blocks, root); 423 421 if (commit.did !== did) { 424 422 throw new Error(`Invalid repo did: ${commit.did}`); 425 423 } ··· 429 427 throw new Error(`Invalid signature on commit: ${root.toString()}`); 430 428 } 431 429 432 - const mst = MST.load(blockstore, (commit as { data: Cid }).data); 433 - const cid = await mst.get(`${collection}/${rkey}`); 430 + const cid = findRecordCid(blocks, commit.data, `${collection}/${rkey}`); 434 431 if (!cid) { 435 432 throw new Error("Record not found in proof"); 436 433 } 437 434 438 - const record = blockstore.readRecord(cid); 435 + const record = readRecordBlock(blocks, cid); 439 436 if (record.$type !== collection) { 440 437 throw new Error( 441 438 `Invalid record type: expected ${collection}, got ${record.$type}`, ··· 447 444 lexicon: record as LexiconDocument, 448 445 }; 449 446 } 447 + 448 + type BlockStore = Map<string, Uint8Array>; 449 + 450 + type CarHeader = { 451 + version: 1; 452 + roots: Cid[]; 453 + }; 454 + 455 + type Commit = { 456 + did: string; 457 + version: 3; 458 + data: Cid; 459 + rev: string; 460 + prev: Cid | null; 461 + sig: Uint8Array; 462 + }; 463 + 464 + type MstEntry = { 465 + p: number; 466 + k: Uint8Array; 467 + v: Cid; 468 + t: Cid | null; 469 + }; 470 + 471 + type MstNode = { 472 + l: Cid | null; 473 + e: MstEntry[]; 474 + }; 475 + 476 + type LexRecord = { 477 + [key: string]: unknown; 478 + $type?: unknown; 479 + }; 480 + 481 + const textDecoder = new TextDecoder(); 482 + 483 + async function readCarWithRoot( 484 + bytes: Uint8Array, 485 + ): Promise<{ root: Cid; blocks: BlockStore }> { 486 + const { header, blocks } = await readCar(bytes); 487 + if (header.roots.length !== 1) { 488 + throw new Error(`Expected one root, got ${header.roots.length}`); 489 + } 490 + 491 + return { 492 + root: header.roots[0], 493 + blocks, 494 + }; 495 + } 496 + 497 + async function readCar( 498 + bytes: Uint8Array, 499 + ): Promise<{ header: CarHeader; blocks: BlockStore }> { 500 + let offset = 0; 501 + const headerSize = readVarint(bytes, offset); 502 + offset = headerSize.offset; 503 + 504 + const header = parseCarHeader(readSlice(bytes, offset, headerSize.value)); 505 + offset += headerSize.value; 506 + 507 + const blocks: BlockStore = new Map(); 508 + while (offset < bytes.byteLength) { 509 + const blockSize = readVarint(bytes, offset); 510 + offset = blockSize.offset; 511 + 512 + const blockBytes = readSlice(bytes, offset, blockSize.value); 513 + offset += blockSize.value; 514 + 515 + const cid = parseCidFromBytes(blockBytes.subarray(0, 36)); 516 + const block = blockBytes.subarray(36); 517 + await verifyCidForBytes(cid, block); 518 + blocks.set(cid.toString(), block); 519 + } 520 + 521 + return { 522 + header, 523 + blocks, 524 + }; 525 + } 526 + 527 + function readVarint( 528 + bytes: Uint8Array, 529 + offset: number, 530 + ): { value: number; offset: number } { 531 + let value = 0; 532 + let shift = 0; 533 + let cursor = offset; 534 + 535 + while (cursor < bytes.byteLength) { 536 + const byte = bytes[cursor]; 537 + value |= (byte & 0x7f) << shift; 538 + cursor += 1; 539 + 540 + if ((byte & 0x80) === 0) { 541 + return { value, offset: cursor }; 542 + } 543 + 544 + shift += 7; 545 + } 546 + 547 + throw new Error("Invalid varint"); 548 + } 549 + 550 + function readSlice( 551 + bytes: Uint8Array, 552 + offset: number, 553 + length: number, 554 + ): Uint8Array { 555 + const end = offset + length; 556 + if (end > bytes.byteLength) { 557 + throw new Error("Unexpected end of CAR data"); 558 + } 559 + 560 + return bytes.subarray(offset, end); 561 + } 562 + 563 + function parseCarHeader(bytes: Uint8Array): CarHeader { 564 + const value = decodeCbor(bytes); 565 + const record = asObject(value); 566 + const version = record.version; 567 + const rootsValue = record.roots; 568 + 569 + if (version !== 1 || !Array.isArray(rootsValue)) { 570 + throw new Error("Invalid CAR header"); 571 + } 572 + 573 + const roots = rootsValue.map((root) => { 574 + const cid = asCid(root); 575 + if (cid == null) { 576 + throw new Error("Invalid CAR root"); 577 + } 578 + return cid; 579 + }); 580 + 581 + return { 582 + version: 1, 583 + roots, 584 + }; 585 + } 586 + 587 + function readCommitBlock(blocks: BlockStore, cid: Cid): Commit { 588 + return parseCommit(readDecodedBlock(blocks, cid)); 589 + } 590 + 591 + function parseCommit(value: unknown): Commit { 592 + const record = asObject(value); 593 + const data = asCid(record.data); 594 + const prev = record.prev === null ? null : asCid(record.prev); 595 + 596 + if ( 597 + typeof record.did !== "string" || 598 + record.version !== 3 || 599 + data == null || 600 + typeof record.rev !== "string" || 601 + !(record.sig instanceof Uint8Array) || 602 + (record.prev !== null && prev == null) 603 + ) { 604 + throw new Error("Invalid repo commit"); 605 + } 606 + 607 + return { 608 + did: record.did, 609 + version: 3, 610 + data, 611 + rev: record.rev, 612 + prev, 613 + sig: record.sig as Uint8Array, 614 + }; 615 + } 616 + 617 + function verifyCommitSig(commit: Commit, didKey: string): boolean { 618 + const { sig, ...unsigned } = commit; 619 + return crypto.verifySignature( 620 + didKey, 621 + encodeCbor(unsigned), 622 + sig, 623 + ); 624 + } 625 + 626 + function findRecordCid( 627 + blocks: BlockStore, 628 + root: Cid, 629 + key: string, 630 + ): Cid | null { 631 + const node = parseMstNode(readDecodedBlock(blocks, root)); 632 + let lastKey = ""; 633 + let subtree = node.l; 634 + 635 + for (const entry of node.e) { 636 + const entryKey = lastKey.slice(0, entry.p) + textDecoder.decode(entry.k); 637 + if (key < entryKey) { 638 + return subtree ? findRecordCid(blocks, subtree, key) : null; 639 + } 640 + if (key === entryKey) { 641 + return entry.v; 642 + } 643 + 644 + subtree = entry.t; 645 + lastKey = entryKey; 646 + } 647 + 648 + return subtree ? findRecordCid(blocks, subtree, key) : null; 649 + } 650 + 651 + function parseMstNode(value: unknown): MstNode { 652 + const record = asObject(value); 653 + const entriesValue = record.e; 654 + 655 + if (!Array.isArray(entriesValue)) { 656 + throw new Error("Invalid MST node"); 657 + } 658 + 659 + const left = record.l === null ? null : asCid(record.l); 660 + if (record.l !== null && left == null) { 661 + throw new Error("Invalid MST node"); 662 + } 663 + 664 + return { 665 + l: left, 666 + e: entriesValue.map(parseMstEntry), 667 + }; 668 + } 669 + 670 + function parseMstEntry(value: unknown): MstEntry { 671 + const record = asObject(value); 672 + const cid = asCid(record.v); 673 + const subtree = record.t === null ? null : asCid(record.t); 674 + 675 + if ( 676 + !Number.isInteger(record.p) || 677 + !(record.k instanceof Uint8Array) || 678 + cid == null || 679 + (record.t !== null && subtree == null) 680 + ) { 681 + throw new Error("Invalid MST entry"); 682 + } 683 + 684 + return { 685 + p: record.p as number, 686 + k: record.k as Uint8Array, 687 + v: cid, 688 + t: subtree, 689 + }; 690 + } 691 + 692 + function readRecordBlock(blocks: BlockStore, cid: Cid): LexRecord { 693 + const value = readDecodedBlock(blocks, cid); 694 + return asObject(value) as LexRecord; 695 + } 696 + 697 + function readDecodedBlock(blocks: BlockStore, cid: Cid): unknown { 698 + const bytes = blocks.get(cid.toString()); 699 + if (bytes == null) { 700 + throw new Error(`Missing block: ${cid.toString()}`); 701 + } 702 + 703 + return decodeCbor(bytes); 704 + } 705 + 706 + function asObject(value: unknown): Record<string, unknown> { 707 + if (value == null || typeof value !== "object" || Array.isArray(value)) { 708 + throw new Error("Expected object"); 709 + } 710 + 711 + return value as Record<string, unknown>; 712 + }
+98 -27
lex/tests/lex-resolver_test.ts
··· 1 - import { cidForCbor, streamToBuffer } from "@atp/common"; 2 1 import * as crypto from "@atp/crypto"; 3 - import { 4 - getRecords, 5 - MemoryBlockstore, 6 - Repo, 7 - type RepoInputRecord, 8 - WriteOpAction, 9 - } from "@atp/repo"; 10 2 import { AtUri } from "@atp/syntax"; 11 3 import { assertEquals, assertInstanceOf, assertRejects } from "@std/assert"; 4 + import { cidForCbor, encode } from "../cbor/mod.ts"; 5 + import type { Cid, LexMap } from "../data/mod.ts"; 12 6 import { LexResolver, LexResolverError } from "../resolver/mod.ts"; 13 7 import { createDefaultResolveTxt } from "../resolver/lex-resolver.ts"; 14 8 ··· 21 15 did: string; 22 16 pds: string; 23 17 signingKey: string; 24 - lexicon: Record<string, unknown>; 18 + lexicon: LexMap; 19 + }; 20 + 21 + type CarBlock = { 22 + cid: Cid; 23 + bytes: Uint8Array; 24 + }; 25 + 26 + type Commit = { 27 + did: string; 28 + version: 3; 29 + data: Cid; 30 + rev: string; 31 + prev: null; 25 32 }; 26 33 27 34 const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { ··· 32 39 33 40 const createLexiconRecord = ( 34 41 id: string, 35 - ): RepoInputRecord => ({ 42 + ): LexMap => ({ 36 43 $type: collection, 37 44 lexicon: 1, 38 45 id, ··· 46 53 const createProofFixture = async ( 47 54 record = createLexiconRecord(nsid), 48 55 ): Promise<ProofFixture> => { 49 - const storage = new MemoryBlockstore(); 50 56 const keypair = crypto.Secp256k1Keypair.create(); 51 57 const did = "did:plc:resolvertest"; 52 - const repo = await Repo.create(storage, did, keypair, [{ 53 - action: WriteOpAction.Create, 54 - collection, 55 - rkey: nsid, 56 - record, 57 - }]); 58 - const car = await streamToBuffer(getRecords(storage, repo.cid, [{ 59 - collection, 60 - rkey: nsid, 61 - }])); 62 - const fetched = await repo.data.get(`${collection}/${nsid}`); 63 - if (!fetched) { 64 - throw new Error("expected cid"); 65 - } 58 + const key = `${collection}/${nsid}`; 59 + const recordBytes = encode(record); 60 + const recordCid = await cidForCbor(recordBytes); 61 + const mstBytes = encode({ 62 + l: null, 63 + e: [{ 64 + p: 0, 65 + k: new TextEncoder().encode(key), 66 + v: recordCid, 67 + t: null, 68 + }], 69 + }); 70 + const mstCid = await cidForCbor(mstBytes); 71 + const unsignedCommit: Commit = { 72 + did, 73 + version: 3, 74 + data: mstCid, 75 + rev: "rev-resolvertest", 76 + prev: null, 77 + }; 78 + const sig = keypair.sign(encode(unsignedCommit)); 79 + const commitBytes = encode({ 80 + ...unsignedCommit, 81 + sig, 82 + }); 83 + const commitCid = await cidForCbor(commitBytes); 84 + const car = encodeCar(commitCid, [ 85 + { cid: commitCid, bytes: commitBytes }, 86 + { cid: mstCid, bytes: mstBytes }, 87 + { cid: recordCid, bytes: recordBytes }, 88 + ]); 89 + 66 90 return { 67 91 car, 68 - cid: fetched.toString(), 92 + cid: recordCid.toString(), 69 93 did, 70 94 pds: "https://pds.test", 71 95 signingKey: keypair.did(), 72 96 lexicon: record, 73 97 }; 74 98 }; 99 + 100 + function encodeCar( 101 + root: Cid, 102 + blocks: CarBlock[], 103 + ): Uint8Array { 104 + const header = encode({ 105 + version: 1, 106 + roots: [root], 107 + }); 108 + const chunks: Uint8Array[] = [encodeVarint(header.byteLength), header]; 109 + 110 + for (const block of blocks) { 111 + const size = block.cid.bytes.byteLength + block.bytes.byteLength; 112 + chunks.push(encodeVarint(size), block.cid.bytes, block.bytes); 113 + } 114 + 115 + return concatBytes(chunks); 116 + } 117 + 118 + function encodeVarint(value: number): Uint8Array { 119 + const bytes: number[] = []; 120 + let next = value; 121 + 122 + do { 123 + let byte = next & 0x7f; 124 + next >>= 7; 125 + if (next > 0) { 126 + byte |= 0x80; 127 + } 128 + bytes.push(byte); 129 + } while (next > 0); 130 + 131 + return Uint8Array.from(bytes); 132 + } 133 + 134 + function concatBytes(chunks: Uint8Array[]): Uint8Array { 135 + const length = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); 136 + const bytes = new Uint8Array(length); 137 + let offset = 0; 138 + 139 + for (const chunk of chunks) { 140 + bytes.set(chunk, offset); 141 + offset += chunk.byteLength; 142 + } 143 + 144 + return bytes; 145 + } 75 146 76 147 Deno.test("get resolves and fetches lexicons through xrpc", async () => { 77 148 const fixture = await createProofFixture(); ··· 141 212 142 213 Deno.test("resolve and fetch hooks can short-circuit the network path", async () => { 143 214 const uri = AtUri.make("did:plc:hooked", collection, nsid); 144 - const cid = await cidForCbor({ ok: true }); 215 + const cid = await cidForCbor(encode({ ok: true })); 145 216 const lexicon = createLexiconRecord(nsid); 146 217 147 218 const resolver = new LexResolver({