~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
833 lines · 24.4 KB Raw History
// @pdsjs/core/repo - Repository utilities for AT Protocol
// CBOR encoding, CIDs, TIDs, CAR files, blob handling

import { base32Decode, base32Encode } from './crypto.js';

// CBOR primitive markers (RFC 8949)
const CBOR_FALSE = 0xf4;
const CBOR_TRUE = 0xf5;
const CBOR_NULL = 0xf6;

// DAG-CBOR CID link tag
const CBOR_TAG_CID = 42;

// CID codec constants
const CODEC_DAG_CBOR = 0x71;
const CODEC_RAW = 0x55;

// TID generation constants
const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
let lastTimestamp = 0;
const clockId = Math.floor(Math.random() * 1024);

/**
 * Encode integer as unsigned varint
 * @param {number} n - Non-negative integer
 * @returns {Uint8Array} Varint-encoded bytes
 */
export function varint(n) {
  const bytes = [];
  while (n >= 0x80) {
    bytes.push((n & 0x7f) | 0x80);
    n >>>= 7;
  }
  bytes.push(n);
  return new Uint8Array(bytes);
}

// === CID WRAPPER ===
// Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection)

export class CID {
  /** @param {Uint8Array} bytes */
  constructor(bytes) {
    if (!(bytes instanceof Uint8Array)) {
      throw new Error('CID must be constructed with Uint8Array');
    }
    this.bytes = bytes;
  }
}

/**
 * Encode CBOR type header (major type + length)
 * @param {number[]} parts - Array to push bytes to
 * @param {number} majorType - CBOR major type (0-7)
 * @param {number} length - Value or length to encode
 */
function encodeHead(parts, majorType, length) {
  const mt = majorType << 5;
  if (length < 24) {
    parts.push(mt | length);
  } else if (length < 256) {
    parts.push(mt | 24, length);
  } else if (length < 65536) {
    parts.push(mt | 25, length >> 8, length & 0xff);
  } else if (length < 4294967296) {
    // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow
    parts.push(
      mt | 26,
      Math.floor(length / 0x1000000) & 0xff,
      Math.floor(length / 0x10000) & 0xff,
      Math.floor(length / 0x100) & 0xff,
      length & 0xff,
    );
  } else if (length <= Number.MAX_SAFE_INTEGER) {
    const high = Math.floor(length / 0x100000000);
    const low = length % 0x100000000;
    parts.push(mt | 27);
    for (const word of [high, low]) {
      parts.push(
        Math.floor(word / 0x1000000) & 0xff,
        Math.floor(word / 0x10000) & 0xff,
        Math.floor(word / 0x100) & 0xff,
        word & 0xff,
      );
    }
  } else {
    throw new RangeError(
      `CBOR length ${length} is past Number.MAX_SAFE_INTEGER`,
    );
  }
}

/**
 * The error for a value an encoder cannot write.
 * @param {unknown} val
 * @returns {TypeError}
 */
function unencodable(val) {
  const what = typeof val === 'number' ? String(val) : typeof val;
  return new TypeError(
    `CBOR cannot encode ${what}: only safe integers, strings, bytes, arrays, maps, booleans and null`,
  );
}

/**
 * Append bytes one at a time. A spread (`parts.push(...bytes)`) passes every
 * byte as a call argument, and past the engine's argument limit (about 125k)
 * it throws RangeError. A commit event carries a whole CAR file, so the
 * inputs here are routinely larger than that.
 * @param {number[]} parts
 * @param {Uint8Array|number[]} bytes
 */
function pushBytes(parts, bytes) {
  for (let i = 0; i < bytes.length; i++) parts.push(bytes[i]);
}

/**
 * Encode a value as CBOR bytes (RFC 8949 deterministic encoding)
 * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object)
 * @returns {Uint8Array} CBOR-encoded bytes
 */
export function cborEncode(value) {
  /** @type {number[]} */
  const parts = [];

  /** @param {*} val */
  function encode(val) {
    if (val === null) {
      parts.push(CBOR_NULL);
    } else if (val === true) {
      parts.push(CBOR_TRUE);
    } else if (val === false) {
      parts.push(CBOR_FALSE);
    } else if (typeof val === 'number') {
      if (!Number.isSafeInteger(val)) throw unencodable(val);
      encodeInteger(val);
    } else if (typeof val === 'string') {
      const bytes = new TextEncoder().encode(val);
      encodeHead(parts, 3, bytes.length); // major type 3 = text string
      pushBytes(parts, bytes);
    } else if (val instanceof Uint8Array) {
      encodeHead(parts, 2, val.length); // major type 2 = byte string
      pushBytes(parts, val);
    } else if (Array.isArray(val)) {
      encodeHead(parts, 4, val.length); // major type 4 = array
      for (const item of val) encode(item);
    } else if (typeof val === 'object') {
      // Sort keys for deterministic encoding
      const keys = Object.keys(val).sort();
      encodeHead(parts, 5, keys.length); // major type 5 = map
      for (const key of keys) {
        encode(key);
        encode(val[key]);
      }
    } else {
      throw unencodable(val);
    }
  }

  /** @param {number} n */
  function encodeInteger(n) {
    if (n >= 0) {
      encodeHead(parts, 0, n); // major type 0 = unsigned int
    } else {
      encodeHead(parts, 1, -n - 1); // major type 1 = negative int
    }
  }

  encode(value);
  return new Uint8Array(parts);
}

/**
 * DAG-CBOR encoder that handles CIDs with tag 42
 * @param {*} value
 * @returns {Uint8Array}
 */
export function cborEncodeDagCbor(value) {
  /** @type {number[]} */
  const parts = [];

  /** @param {*} val */
  function encode(val) {
    if (val === null) {
      parts.push(CBOR_NULL);
    } else if (val === true) {
      parts.push(CBOR_TRUE);
    } else if (val === false) {
      parts.push(CBOR_FALSE);
    } else if (typeof val === 'number') {
      if (!Number.isSafeInteger(val)) throw unencodable(val);
      if (val >= 0) encodeHead(parts, 0, val);
      else encodeHead(parts, 1, -val - 1);
    } else if (typeof val === 'string') {
      const bytes = new TextEncoder().encode(val);
      encodeHead(parts, 3, bytes.length);
      pushBytes(parts, bytes);
    } else if (val instanceof CID) {
      // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix
      // The 0x00 prefix indicates "identity" multibase (raw bytes)
      parts.push(0xd8, CBOR_TAG_CID);
      encodeHead(parts, 2, val.bytes.length + 1); // +1 for 0x00 prefix
      parts.push(0x00);
      pushBytes(parts, val.bytes);
    } else if (val instanceof Uint8Array) {
      // Regular byte string
      encodeHead(parts, 2, val.length);
      pushBytes(parts, val);
    } else if (Array.isArray(val)) {
      encodeHead(parts, 4, val.length);
      for (const item of val) encode(item);
    } else if (typeof val === 'object') {
      // DAG-CBOR: sort keys by length first, then lexicographically
      // (differs from standard CBOR which sorts lexicographically only)
      const keys = Object.keys(val).filter((k) => val[k] !== undefined);
      keys.sort((a, b) => {
        if (a.length !== b.length) return a.length - b.length;
        return a < b ? -1 : a > b ? 1 : 0;
      });
      encodeHead(parts, 5, keys.length);
      for (const key of keys) {
        const keyBytes = new TextEncoder().encode(key);
        encodeHead(parts, 3, keyBytes.length);
        pushBytes(parts, keyBytes);
        encode(val[key]);
      }
    } else {
      throw unencodable(val);
    }
  }

  encode(value);
  return new Uint8Array(parts);
}

/**
 * Decode CBOR bytes to a JavaScript value
 * @param {Uint8Array} bytes - CBOR-encoded bytes
 * @returns {*} Decoded value
 */
export function cborDecode(bytes) {
  return cborDecodeFirst(bytes)[0];
}

/**
 * Decode CBOR bytes, keeping a CID link apart from a byte string: tag 42 comes
 * back as a CID rather than as its raw bytes.
 * @param {Uint8Array} bytes - CBOR-encoded bytes
 * @returns {*} Decoded value
 */
export function cborDecodeLinks(bytes) {
  return cborDecodeFirst(bytes, { links: true })[0];
}

/**
 * Decode the first CBOR item in a byte stream, returning the value and the
 * number of bytes consumed. Useful for parsing firehose frames, which are a
 * header item followed by a body item.
 * @param {Uint8Array} bytes - CBOR-encoded bytes
 * @param {{links?: boolean}} [opts] - links: return tag-42 links as CIDs
 * @returns {[*, number]} Decoded value and bytes consumed
 */
export function cborDecodeFirst(bytes, opts = {}) {
  let offset = 0;

  /** @returns {*} */
  function read() {
    const initial = bytes[offset++];
    const major = initial >> 5;
    const info = initial & 0x1f;

    let length = info;
    if (info === 24) length = bytes[offset++];
    else if (info === 25) {
      length = (bytes[offset++] << 8) | bytes[offset++];
    } else if (info === 26) {
      // Use multiplication instead of bitshift to avoid 32-bit signed integer overflow
      length =
        bytes[offset++] * 0x1000000 +
        bytes[offset++] * 0x10000 +
        bytes[offset++] * 0x100 +
        bytes[offset++];
    } else if (info === 27) {
      // The encoders write an integer of 2^32 or more with an 8-byte head.
      length = 0;
      for (let i = 0; i < 8; i++) length = length * 0x100 + bytes[offset++];
    }

    switch (major) {
      case 0:
        return length; // unsigned int
      case 1:
        return -1 - length; // negative int
      case 2: {
        // byte string
        const data = bytes.slice(offset, offset + length);
        offset += length;
        return data;
      }
      case 3: {
        // text string
        const data = new TextDecoder().decode(
          bytes.slice(offset, offset + length),
        );
        offset += length;
        return data;
      }
      case 4: {
        // array
        const arr = [];
        for (let i = 0; i < length; i++) arr.push(read());
        return arr;
      }
      case 5: {
        // map
        /** @type {Record<string, *>} */
        const obj = {};
        for (let i = 0; i < length; i++) {
          const key = /** @type {string} */ (read());
          obj[key] = read();
        }
        return obj;
      }
      case 6: {
        // tag
        // length is the tag number
        const taggedValue = read();
        if (length === CBOR_TAG_CID) {
          // CID link: byte string with 0x00 multibase prefix
          const cid = taggedValue.slice(1);
          return opts.links ? new CID(cid) : cid;
        }
        return taggedValue;
      }
      case 7: {
        // special
        if (info === 20) return false;
        if (info === 21) return true;
        if (info === 22) return null;
        return undefined;
      }
    }
  }

  return [read(), offset];
}

/**
 * Parse a (signed or unsigned) commit block.
 * @param {Uint8Array} bytes - DAG-CBOR encoded commit block
 * @returns {{did: string, rev: string, data: string|null, prev: string|null}}
 */
export function parseCommitBlock(bytes) {
  const commit = cborDecode(bytes);
  return {
    did: commit.did,
    rev: commit.rev,
    data: commit.data ? cidToString(commit.data) : null,
    prev: commit.prev ? cidToString(commit.prev) : null,
  };
}

/**
 * @param {BufferSource} data
 * @returns {Promise<Uint8Array>}
 */
export async function sha256(data) {
  const hash = await crypto.subtle.digest('SHA-256', data);
  return new Uint8Array(hash);
}

/**
 * Create a CIDv1 with SHA-256 hash
 * @param {Uint8Array} bytes - Content to hash
 * @param {number} codec - Codec identifier (0x71 for dag-cbor, 0x55 for raw)
 * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash)
 */
async function createCidWithCodec(bytes, codec) {
  const hash = await crypto.subtle.digest(
    'SHA-256',
    /** @type {BufferSource} */ (bytes),
  );
  return cidFromDigest(new Uint8Array(hash), codec);
}

/**
 * @param {Uint8Array} digest - 32-byte SHA-256 digest
 * @param {number} codec - Codec identifier (0x71 for dag-cbor, 0x55 for raw)
 * @returns {Uint8Array} CID bytes (36 bytes: version + codec + multihash)
 */
function cidFromDigest(digest, codec) {
  // CIDv1: version(1) + codec + multihash(sha256)
  // Multihash: hash-type(0x12) + length(0x20=32) + digest
  const cid = new Uint8Array(2 + 2 + 32);
  cid[0] = 0x01; // CIDv1
  cid[1] = codec;
  cid[2] = 0x12; // sha-256
  cid[3] = 0x20; // 32 bytes
  cid.set(digest, 4);

  return cid;
}

/**
 * Whether a record URI addresses a space record. A space record's URI puts
 * the literal `space` marker where a public URI has its collection NSID, and
 * an NSID always contains a dot, so the second path segment separates them.
 * @param {string} uri
 * @returns {boolean}
 */
export function isSpaceRecordUri(uri) {
  if (!uri.startsWith('at://')) return false;
  return uri.slice('at://'.length).split('/')[1] === 'space';
}

/**
 * Build the CID for raw blob content from an already computed SHA-256 digest.
 * The streamed upload path hashes bytes as they arrive, so it has no
 * whole-blob buffer to pass to createBlobCid.
 * @param {Uint8Array} digest - 32-byte SHA-256 digest
 * @returns {Uint8Array} CID bytes
 */
export function blobCidFromDigest(digest) {
  return cidFromDigest(digest, CODEC_RAW);
}

/**
 * Create CID for DAG-CBOR encoded data (records, commits)
 * @param {Uint8Array} bytes - DAG-CBOR encoded content
 * @returns {Promise<Uint8Array>} CID bytes
 */
export async function createCid(bytes) {
  return createCidWithCodec(bytes, CODEC_DAG_CBOR);
}

/**
 * Create CID for raw blob data (images, videos)
 * @param {Uint8Array} bytes - Raw binary content
 * @returns {Promise<Uint8Array>} CID bytes
 */
export async function createBlobCid(bytes) {
  return createCidWithCodec(bytes, CODEC_RAW);
}

/**
 * Convert CID bytes to base32lower string representation
 * @param {Uint8Array} cid - CID bytes
 * @returns {string} Base32lower-encoded CID with 'b' prefix
 */
export function cidToString(cid) {
  // base32lower encoding for CIDv1
  return `b${base32Encode(cid)}`;
}

/**
 * Convert base32lower CID string to raw bytes
 * @param {string} cidStr - CID string with 'b' prefix
 * @returns {Uint8Array} CID bytes
 */
export function cidToBytes(cidStr) {
  // Decode base32lower CID string to bytes
  if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID');
  return base32Decode(cidStr.slice(1));
}

/**
 * Generate a timestamp-based ID (TID) for record keys
 * Monotonic within a process, sortable by time
 * @returns {string} 13-character base32-sort encoded TID
 */
export function createTid() {
  let timestamp = Date.now() * 1000; // microseconds

  // Ensure monotonic
  if (timestamp <= lastTimestamp) {
    timestamp = lastTimestamp + 1;
  }
  lastTimestamp = timestamp;

  // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID
  let tid = '';

  // Encode timestamp (high bits first for sortability)
  let ts = timestamp;
  for (let i = 0; i < 11; i++) {
    tid = TID_CHARS[ts & 31] + tid;
    ts = Math.floor(ts / 32);
  }

  // Append clock ID (2 chars)
  tid += TID_CHARS[(clockId >> 5) & 31];
  tid += TID_CHARS[clockId & 31];

  return tid;
}

/**
 * Decode the timestamp a TID encodes, in epoch milliseconds, or null when the
 * string is not a 13-character TID. The first 11 characters hold a microsecond
 * timestamp, high bits first; the trailing two are the clock id and are ignored.
 * The inverse of the timestamp half of {@link createTid}.
 * @param {string} tid
 * @returns {number|null}
 */
export function tidToMs(tid) {
  if (typeof tid !== 'string' || tid.length !== 13) return null;
  let micros = 0;
  for (let i = 0; i < 11; i++) {
    const value = TID_CHARS.indexOf(tid[i]);
    if (value < 0) return null;
    micros = micros * 32 + value;
  }
  return Math.floor(micros / 1000);
}

// Cache for key depths (SHA-256 is expensive)
const keyDepthCache = new Map();

/**
 * Get MST tree depth for a key based on leading zeros in SHA-256 hash
 * @param {string} key - Record key (collection/rkey)
 * @returns {Promise<number>} Tree depth (leading zeros / 2)
 */
export async function getKeyDepth(key) {
  // Count leading zeros in SHA-256 hash, divide by 2
  if (keyDepthCache.has(key)) return keyDepthCache.get(key);

  const keyBytes = new TextEncoder().encode(key);
  const hash = await sha256(keyBytes);

  let zeros = 0;
  for (const byte of hash) {
    if (byte === 0) {
      zeros += 8;
    } else {
      // Count leading zeros in this byte
      for (let i = 7; i >= 0; i--) {
        if ((byte >> i) & 1) break;
        zeros++;
      }
      break;
    }
  }

  // MST depth = leading zeros in SHA-256 hash / 2
  // This creates a probabilistic tree where ~50% of keys are at depth 0,
  // ~25% at depth 1, etc., giving O(log n) lookups
  const depth = Math.floor(zeros / 2);
  keyDepthCache.set(key, depth);
  return depth;
}

/**
 * Build a CAR (Content Addressable aRchive) file
 * @param {string|string[]} rootCid - Root CID string, or an ordered list of them
 * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include
 * @returns {Uint8Array} CAR file bytes
 */
export function buildCarFile(rootCid, blocks) {
  return concatBytes([carHeader(rootCid), ...blocks.map(carBlock)]);
}

/**
 * A CAR file's header, which a stream of {@link carBlock}s follows.
 *
 * Header: { version: 1, roots: [...] }. Most callers have a single root;
 * permissioned repos (proposal 0016) declare two — a signed commit and an
 * index — so an array is accepted as well.
 * @param {string|string[]} rootCid
 * @returns {Uint8Array}
 */
export function carHeader(rootCid) {
  const rootCids = Array.isArray(rootCid) ? rootCid : [rootCid];
  const header = cborEncodeDagCbor({
    version: 1,
    roots: rootCids.map((cid) => new CID(cidToBytes(cid))),
  });
  return concatBytes([varint(header.length), header]);
}

/**
 * One block of a CAR file: varint(len) + cid + data.
 * @param {{cid: string, data: Uint8Array}} block
 * @returns {Uint8Array}
 */
export function carBlock(block) {
  const cidBytes = cidToBytes(block.cid);
  const blockLen = cidBytes.length + block.data.length;
  return concatBytes([varint(blockLen), cidBytes, block.data]);
}

/**
 * @param {Uint8Array[]} parts
 * @returns {Uint8Array}
 */
function concatBytes(parts) {
  const totalLen = parts.reduce((sum, p) => sum + p.length, 0);
  const out = new Uint8Array(totalLen);
  let offset = 0;
  for (const part of parts) {
    out.set(part, offset);
    offset += part.length;
  }
  return out;
}

/**
 * Sniff MIME type from file magic bytes
 * @param {Uint8Array|ArrayBuffer} bytes - File bytes (only first 12 needed)
 * @returns {string|null} Detected MIME type or null if unknown
 */
export function sniffMimeType(bytes) {
  const arr = new Uint8Array(bytes.slice(0, 12));

  // JPEG: FF D8 FF
  if (arr[0] === 0xff && arr[1] === 0xd8 && arr[2] === 0xff) {
    return 'image/jpeg';
  }

  // PNG: 89 50 4E 47 0D 0A 1A 0A
  if (
    arr[0] === 0x89 &&
    arr[1] === 0x50 &&
    arr[2] === 0x4e &&
    arr[3] === 0x47 &&
    arr[4] === 0x0d &&
    arr[5] === 0x0a &&
    arr[6] === 0x1a &&
    arr[7] === 0x0a
  ) {
    return 'image/png';
  }

  // GIF: 47 49 46 38 (GIF8)
  if (
    arr[0] === 0x47 &&
    arr[1] === 0x49 &&
    arr[2] === 0x46 &&
    arr[3] === 0x38
  ) {
    return 'image/gif';
  }

  // WebP: RIFF....WEBP
  if (
    arr[0] === 0x52 &&
    arr[1] === 0x49 &&
    arr[2] === 0x46 &&
    arr[3] === 0x46 &&
    arr[8] === 0x57 &&
    arr[9] === 0x45 &&
    arr[10] === 0x42 &&
    arr[11] === 0x50
  ) {
    return 'image/webp';
  }

  // ISOBMFF container: ....ftyp at byte 4 (MP4, AVIF, HEIC, etc.)
  if (
    arr[4] === 0x66 &&
    arr[5] === 0x74 &&
    arr[6] === 0x79 &&
    arr[7] === 0x70
  ) {
    // Check brand code at bytes 8-11
    const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]);
    if (brand === 'avif') {
      return 'image/avif';
    }
    if (brand === 'heic' || brand === 'heix' || brand === 'mif1') {
      return 'image/heic';
    }
    return 'video/mp4';
  }

  return null;
}

// A field that carries a moment, by naming convention across lexicons:
// createdAt, postedAt, playedTime, publishDate, and the like.
const TIME_KEY = /(at|time|date|timestamp)$/i;

// Only moments inside this window date a record: nothing before the protocol
// existed (a profile's birthDate must not date an avatar), nothing from the
// future (a scheduled event must not pin its poster to that day).
const RECORD_TIME_FLOOR = Date.parse('2020-01-01T00:00:00Z');

/**
 * The moment a record says its content was made, or null when it carries
 * none. Lexicons name their timestamps freely (`createdAt`, `postedAt`,
 * `playedTime`, …), so any top-level field named like a timestamp whose value
 * is an ISO datetime counts, and the earliest wins — an update time can only
 * be later than creation, so the minimum is the creation-most moment. Used to
 * date the blobs a record references by the record's own clock, which unlike
 * server arrival time survives a migration.
 * @param {*} value - Record value
 * @returns {number|null}
 */
export function recordTimeOf(value) {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
  let earliest = null;
  const ceiling = Date.now() + 24 * 60 * 60 * 1000;
  for (const [key, raw] of Object.entries(value)) {
    if (!TIME_KEY.test(key)) continue;
    if (typeof raw !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(raw)) continue;
    const parsed = Date.parse(raw);
    if (!Number.isFinite(parsed)) continue;
    if (parsed < RECORD_TIME_FLOOR || parsed > ceiling) continue;
    if (earliest === null || parsed < earliest) earliest = parsed;
  }
  return earliest;
}

/**
 * Find all blob CID references in a record
 * @param {*} obj - Record value to scan
 * @param {string[]} refs - Accumulator array (internal)
 * @returns {string[]} Array of blob CID strings
 */
export function findBlobRefs(obj, refs = []) {
  if (!obj || typeof obj !== 'object') {
    return refs;
  }

  // Check if this object is a blob ref. A ref written over XRPC is `{$link}`;
  // one decoded from a CAR (an imported repo) is the raw CID bytes, since
  // cborDecode hands back tag-42 links as bytes. Both must be found, or an
  // imported account's blobs go unseen and never transfer.
  if (obj.$type === 'blob') {
    if (obj.ref?.$link) {
      refs.push(obj.ref.$link);
    } else if (obj.ref instanceof Uint8Array) {
      refs.push(cidToString(obj.ref));
    }
  }

  // Legacy (pre-$type) blob ref: exactly {cid, mimeType}
  const keys = Object.keys(obj);
  if (
    keys.length === 2 &&
    typeof obj.cid === 'string' &&
    typeof obj.mimeType === 'string'
  ) {
    refs.push(obj.cid);
  }

  // Recurse into arrays and objects
  if (Array.isArray(obj)) {
    for (const item of obj) {
      findBlobRefs(item, refs);
    }
  } else {
    for (const value of Object.values(obj)) {
      findBlobRefs(value, refs);
    }
  }

  return refs;
}

/**
 * The DAG-CBOR data model of a record in its JSON form: `{$link}` becomes a
 * CID link and `{$bytes}` a byte string, so the record encodes to the CID any
 * other implementation computes from the same JSON.
 * @param {unknown} value
 * @returns {unknown}
 */
export function jsonToLex(value) {
  if (Array.isArray(value)) return value.map(jsonToLex);
  if (value && typeof value === 'object' && !(value instanceof CID)) {
    const obj = /** @type {Record<string, unknown>} */ (value);
    if (obj instanceof Uint8Array) return obj;
    const keys = Object.keys(obj);
    if (keys.length === 1 && typeof obj.$link === 'string') {
      return new CID(cidToBytes(obj.$link));
    }
    if (keys.length === 1 && typeof obj.$bytes === 'string') {
      return Uint8Array.from(atob(obj.$bytes), (c) => c.charCodeAt(0));
    }
    return Object.fromEntries(
      Object.entries(obj).map(([key, inner]) => [key, jsonToLex(inner)]),
    );
  }
  return value;
}

/**
 * The JSON form of a record decoded by cborDecodeLinks.
 * @param {unknown} value
 * @returns {unknown}
 */
export function lexToJson(value) {
  if (value instanceof CID) return { $link: cidToString(value.bytes) };
  if (value instanceof Uint8Array) {
    let binary = '';
    for (const b of value) binary += String.fromCharCode(b);
    return { $bytes: btoa(binary).replace(/=+$/, '') };
  }
  if (Array.isArray(value)) return value.map(lexToJson);
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, inner]) => [key, lexToJson(inner)]),
    );
  }
  return value;
}

/**
 * A stored record, in its JSON form.
 * @param {Uint8Array} bytes - DAG-CBOR
 * @returns {any}
 */
export function cborDecodeJson(bytes) {
  return lexToJson(cborDecodeLinks(bytes));
}

/**
 * Make a decoded record safe to render as JSON. DAG-CBOR gives back raw bytes
 * for a CID link, which stringifies as an object full of numbers, and
 * everything else in a record is already plain JSON.
 * @param {unknown} value
 * @returns {unknown}
 */
export function toDisplayValue(value) {
  if (value instanceof Uint8Array) return { $link: cidToString(value) };
  if (Array.isArray(value)) return value.map(toDisplayValue);
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, inner]) => [key, toDisplayValue(inner)]),
    );
  }
  return value;
}