// The backup archive (design ยง18, "Backups"): a private space's corpus in a file. // // The property under test throughout is that the file is *not* a trust input. It carries envelopes // and the blobs their records name, every envelope still has to pass `admit()`, every blob is stored // under the name its own bytes hash to, and a header that disagrees with the body โ€” a truncated copy, // an edited record โ€” is refused rather than half-restored. import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { COLLECTIONS } from '../dist/generated/records.js' import { ARCHIVE_FORMAT, ARCHIVE_VERSION, MAX_ARCHIVE_WANTS, decodeArchive, encodeArchive, } from '../dist/private/archive.js' import { admit } from '../dist/private/admission.js' import { blobCid } from '../dist/private/blobs.js' import { exportPublicKey, generateDeviceKey, seal } from '../dist/private/envelope.js' import { recordCid } from '../dist/private/cid.js' const SPACE = 'at://did:plc:privateroot/com.disnetdev.radial.space/space' const DID = 'did:plc:privatehuman' const KEY_ID = 'node-archive-1' const messageRecord = (body) => ({ $type: COLLECTIONS.message, goal: { uri: 'at://did:plc:privatehuman/com.disnetdev.radial.goal/goal', cid: 'bafyreigoal' }, body, mentions: [], createdAt: '2026-03-01T00:00:00Z', }) async function signer() { const pair = await generateDeviceKey(true) return { publicKey: await exportPublicKey(pair.publicKey), privateKey: pair.privateKey } } const sealFor = async (key, body, rkey) => seal({ space: SPACE, did: DID, collection: COLLECTIONS.message, rkey, record: messageRecord(body), rev: '3ms26zx46yg2f', deviceKeyId: KEY_ID, privateKey: key.privateKey, }) const header = { space: SPACE, founder: 'did:plc:privateroot', exportedAt: '2026-03-01T00:00:00Z' } const lookupOf = (key) => ({ publicKeyFor: (did, keyId) => (did === DID && keyId === KEY_ID ? key.publicKey : undefined), }) describe('the archive format', () => { it('round-trips every envelope, header first', async () => { const key = await signer() const envelopes = [await sealFor(key, 'one', 'a'), await sealFor(key, 'two', 'b')] const text = encodeArchive(header, envelopes) const decoded = decodeArchive(text) assert.equal(decoded.header.format, ARCHIVE_FORMAT) // A file declares the version its contents need, and this one holds no blobs: a build predating // them can restore it, so it is not stamped with a version that would make it refuse. assert.equal(decoded.header.version, 1) assert.deepEqual(decoded.blobs, []) assert.equal(decoded.header.space, SPACE) assert.equal(decoded.header.envelopes, 2) assert.deepEqual( decoded.envelopes.map((envelope) => envelope.record.body).sort(), ['one', 'two'], ) // One record per line, so an operator can grep a backup and a tool can stream it. assert.equal(text.trimEnd().split('\n').length, 3) }) it('is a function of the corpus: same envelopes, same bytes, in any order and twice over', async () => { const key = await signer() const first = await sealFor(key, 'one', 'a') const second = await sealFor(key, 'two', 'b') assert.equal(encodeArchive(header, [first, second]), encodeArchive(header, [second, first])) // A duplicate is the same envelope, and an archive holds it once โ€” a re-export after a peer // re-offered a record must not grow. const deduped = decodeArchive(encodeArchive(header, [first, second, first])) assert.equal(deduped.envelopes.length, 2) assert.equal(deduped.header.envelopes, 2) }) it('keeps the exporting replica pin as a claim, not as a fact', async () => { const decoded = decodeArchive(encodeArchive({ ...header, spaceCid: 'bafyreigenesis' }, [])) assert.equal(decoded.header.spaceCid, 'bafyreigenesis') assert.equal(decoded.envelopes.length, 0) }) it('carries what the exporting replica knew it was missing', async () => { const cid = await recordCid(messageRecord('missing')) const want = { did: DID, collection: COLLECTIONS.message, rkey: 'evicted', recordCid: cid, } const decoded = decodeArchive(encodeArchive({ ...header, wants: [want] }, [])) assert.deepEqual(decoded.header.wants, [want]) // A want that is not one is refused with the rest of the header, before an import acts on it. const [line] = encodeArchive({ ...header, wants: [want] }, []).split('\n') const broken = JSON.parse(line) broken.wants = [{ did: DID }] assert.throws(() => decodeArchive(`${JSON.stringify(broken)}\n`), /wants entry/) for (const [field, value, message] of [ ['did', '', /field did is missing/], ['did', 'not-a-did', /did is not a DID/], ['collection', 'example.invalid.record', /not a Radial record collection/], ['rkey', 'has a space', /rkey is not a record key/], ['recordCid', 'not-a-cid', /recordCid is not a private record CID/], ['did', `did:plc:${'a'.repeat(2049)}`, /did exceeds 2048 characters/], ]) { broken.wants = [{ ...want, [field]: value }] assert.throws(() => decodeArchive(`${JSON.stringify(broken)}\n`), message) } broken.wants = Array.from({ length: MAX_ARCHIVE_WANTS + 1 }, () => want) assert.throws(() => decodeArchive(`${JSON.stringify(broken)}\n`), /exceeds 1000 entries/) }) it('refuses a truncated file rather than restoring most of a space', async () => { const key = await signer() const text = encodeArchive(header, [await sealFor(key, 'one', 'a'), await sealFor(key, 'two', 'b')]) const lines = text.trimEnd().split('\n') assert.throws( () => decodeArchive(`${lines.slice(0, 2).join('\n')}\n`), /truncated or altered: the header names 2 envelope\(s\) and the file holds 1/, ) }) it('refuses anything that is not an archive of a readable version', () => { assert.throws(() => decodeArchive(''), /the file is empty/) assert.throws(() => decodeArchive('{"format":"something-else"}\n'), /expected a "radial-private-archive" header/) assert.throws( () => decodeArchive(`${JSON.stringify({ format: ARCHIVE_FORMAT, version: 99, space: SPACE, envelopes: 0 })}\n`), /version 99 is not readable by this build/, ) assert.throws( () => decodeArchive(`${JSON.stringify({ format: ARCHIVE_FORMAT, version: ARCHIVE_VERSION, space: 'nope', envelopes: 0 })}\n`), /space must be an "at:\/\/" URI/, ) }) it('carries blobs, counted like the envelopes and named by their own bytes', async () => { const key = await signer() const envelopes = [await sealFor(key, 'one', 'a')] const bytes = new TextEncoder().encode('a picture') const text = encodeArchive(header, envelopes, [{ cid: await blobCid(bytes), bytes }]) const decoded = decodeArchive(text) // Blob lines are what version 2 exists for, so a file holding one says so. assert.equal(decoded.header.version, ARCHIVE_VERSION) assert.equal(decoded.header.blobs, 1) assert.equal(decoded.blobs.length, 1) assert.deepEqual(decoded.blobs[0].bytes, bytes) assert.equal(decoded.blobs[0].cid, await blobCid(bytes)) // A function of the corpus, blobs included: a duplicate is one blob, and order does not matter. assert.equal( text, encodeArchive(header, envelopes, [ { cid: await blobCid(bytes), bytes }, { cid: await blobCid(bytes), bytes }, ]), ) // Dropping the blob lines without correcting the header is a truncation, and says so. const truncated = text.split('\n').filter((line) => !line.includes('"blob":')).join('\n') assert.throws(() => decodeArchive(truncated), /names 1 blob\(s\) and the file holds 0/) }) }) describe('an archive is never a trust input', () => { it('hands every envelope to the same gate a peer would go through', async () => { const key = await signer() const envelope = await sealFor(key, 'one', 'a') const [restored] = decodeArchive(encodeArchive(header, [envelope])).envelopes const outcome = await admit(restored, { spaceUri: SPACE, keys: lookupOf(key) }) assert.equal(outcome.status, 'admitted') assert.equal(outcome.record.value.body, 'one') assert.deepEqual(outcome.record.deviceKeyIds, [KEY_ID]) }) it('cannot smuggle an edited record past admission, even with the CID corrected', async () => { const key = await signer() const text = encodeArchive(header, [await sealFor(key, 'one', 'a')]) const [head, line] = text.trimEnd().split('\n') // The naive tamper: change the body. The signature covers the record, so it dies on the // recomputed CID before a key is ever consulted. const edited = JSON.parse(line) edited.record.body = 'not what was signed' const naive = decodeArchive(`${head}\n${JSON.stringify(edited)}\n`) assert.equal( (await admit(naive.envelopes[0], { spaceUri: SPACE, keys: lookupOf(key) })).reason, 'recordCid does not match the record', ) // The careful tamper: recompute the CID too, so the envelope is internally consistent. The // signature is what is left, and it is the whole point. edited.recordCid = await recordCid(edited.record) const careful = decodeArchive(`${head}\n${JSON.stringify(edited)}\n`) assert.equal( (await admit(careful.envelopes[0], { spaceUri: SPACE, keys: lookupOf(key) })).reason, 'signature does not verify against the published device key', ) }) it('cannot move a corpus into another space by relabelling the header', async () => { const key = await signer() const text = encodeArchive( { ...header, space: 'at://did:plc:other/com.disnetdev.radial.space/other' }, [await sealFor(key, 'one', 'a')], ) const decoded = decodeArchive(text) // The header says one space and the envelope says another; the envelope is what is checked, and // the importer refuses the whole file on the header before it gets this far. assert.equal(decoded.header.space, 'at://did:plc:other/com.disnetdev.radial.space/other') const outcome = await admit(decoded.envelopes[0], { spaceUri: 'at://did:plc:other/com.disnetdev.radial.space/other', keys: lookupOf(key), }) assert.equal(outcome.status, 'rejected') assert.equal(outcome.reason, 'envelope names a different space') }) })