OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / lib / signer.js

91 lines · javascript ref:
// Signer — the lease-holder's job: materialize the linearized repo-op view into ONE canonical
// signed atproto commit. This is the "one net-new consumer" of the reskin: it turns the shared
// autobase's linearized record set into the single-writer signed commit chain the atproto world
// consumes. Signing uses the SHARED #atproto key; because ECDSA here is RFC6979-deterministic,
// any lease-holder that signs the same linearized view + rev/prev produces byte-identical output.
//
// This slice owns ONLY the pure materialize step (record set → signed commit). Chain advancement
// (monotonic rev, prev = last head), firehose emission, and lease-gating are wired in later slices.

import * as dagCbor from '@ipld/dag-cbor'
import { buildMst, firehoseProofBlocks } from 'atproto-repo/lib/mst.js'
import { unsignedCommit, signCommit, commitCid } from 'atproto-repo/lib/commit.js'
import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
import { CID } from 'multiformats/cid'

// records: [{ collection, rkey, record }] — the linearized view (e.g. RepoBase.listRecords()).
// meta: { did, rev, prev=null, privateKey } — the shared #atproto signing key + chain position.
// Returns the signed commit + its CID + the MST data CID + the record blocks (canonical bytes).
export function materialize (records, { did, rev, prev = null, privateKey }) {
  if (!did || !rev || !privateKey) throw new Error('materialize: did, rev, privateKey required')
  const mstRecords = []
  const recordBlocks = []
  for (const { collection, rkey, record } of records) {
    const bytes = dagCbor.encode(record) // canonical DAG-CBOR (content-addressed → preserves record CID)
    const cid = cidForBytesSync(bytes)
    mstRecords.push({ key: collection + '/' + rkey, value: cid })
    recordBlocks.push({ cid, bytes })
  }
  // MST is defined over keys in sorted order — sort so the data root is deterministic regardless
  // of the view's iteration order.
  mstRecords.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
  const { root: dataCid } = buildMst(mstRecords)
  const signed = signCommit(unsignedCommit({ did, rev, data: dataCid, prev }), privateKey)
  return { signedCommit: signed, commitCid: commitCid(signed), dataCid, recordBlocks }
}

// buildCommitWrite — turn a view DIFF (before -> after record sets) into the atproto firehose
// `#commit` write: the signed commit + the MINIMAL Sync-v1.1 covering proof (created |
// deletion_proof | inductive_proof), NOT the whole tree. This is the frame a membrane reads to
// project the swarm to bsky (keyless). Shape matches atproto-store's commitFrame(write, …). The
// lease-holder calls this each sign; because the proof is over the diff, a cold relay can invert
// it with no old-tree state. before/after: [{collection, rkey, record}]. caller supplies chain
// position (prev = last commit CID, since = last rev); prevData is derived from `before`.
export function buildCommitWrite (before, after, { did, rev, prev = null, since = null, privateKey }) {
  if (!did || !rev || !privateKey) throw new Error('buildCommitWrite: did, rev, privateKey required')
  // A `before` entry may carry only `cid` instead of `record`: the proof and the ops need nothing but
  // the old CIDs, and a fork-heal commit diffs from a state assembled out of other commits' ops, whose
  // CIDs are known and whose record bytes may not be (hyper-proto-peer swarm-device sign()).
  const enc = (recs) => {
    const m = new Map()
    for (const { collection, rkey, record, cid } of recs) {
      if (record === undefined && cid) {
        m.set(collection + '/' + rkey, { cid: typeof cid === 'string' ? CID.parse(cid) : cid, bytes: null })
        continue
      }
      const bytes = dagCbor.encode(record)
      m.set(collection + '/' + rkey, { cid: cidForBytesSync(bytes), bytes })
    }
    return m
  }
  const oldM = enc(before)
  const newM = enc(after)

  // diff -> repoOps (create | update | delete). Record blocks travel only for create/update.
  const ops = []
  const opBlocks = []
  for (const [key, { cid, bytes }] of newM) {
    const old = oldM.get(key)
    if (!old) { ops.push({ action: 'create', path: key, cid }); opBlocks.push({ cid, bytes }) } else if (!old.cid.equals(cid)) { ops.push({ action: 'update', path: key, cid, prev: old.cid }); opBlocks.push({ cid, bytes }) }
  }
  for (const [key, { cid }] of oldM) if (!newM.has(key)) ops.push({ action: 'delete', path: key, cid: null, prev: cid })

  const toMst = (m) => [...m.entries()].map(([key, { cid }]) => ({ key, value: cid }))
  // firehoseProofBlocks asserts the covering proof INVERTS to oldRoot (throws otherwise) — so a
  // successful return is itself proof the frame is relay-invertible.
  const proof = firehoseProofBlocks({ oldRecords: toMst(oldM), newRecords: toMst(newM), ops })
  const signed = signCommit(unsignedCommit({ did, rev, data: proof.dataCid, prev }), privateKey)
  const cc = commitCid(signed)
  return {
    commitCid: cc,
    commitBlock: { cid: cc, bytes: dagCbor.encode(signed) },
    signedCommit: signed,
    dataCid: proof.dataCid,
    prevData: proof.oldRoot,
    rev,
    since,
    ops,
    newBlocks: [...proof.blocks.values(), ...opBlocks]
  }
}