#!/usr/bin/env bare
/**
* Integration test: two-way Autobase multi-writer over Hyperswarm.
*
* Proves the correct flow for federation issue sync:
* 1. Server A creates an Autobase, writes issues
* 2. Server B adopts the same Autobase (same bootstrap key)
* 3. Both connect via Hyperswarm testnet and replicate
* 4. Server A adds Server B as a writer using B's base.local.key
* 5. Server B writes issues
* 6. Both servers see all issues from both writers
*
* This is the pattern pear-git needs for LinkedRepo federation.
*/
import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import Hyperswarm from 'hyperswarm'
import createTestnet from '@hyperswarm/testnet'
import os from 'bare-os'
import path from 'bare-path'
import fs from 'bare-fs'
const tmpdir = os.tmpdir()
let idx = 0
function tmpPath (name) {
return path.join(tmpdir, 'ab-mw-' + name + '-' + (idx++) + '-' + Date.now())
}
async function cleanup (...dirs) {
for (const d of dirs) await fs.promises.rm(d, { recursive: true }).catch(() => {})
}
function open (store) {
return new Hyperbee(store.get('view', { valueEncoding: 'json' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
}
async function apply (batch, view, host) {
const b = view.batch()
for (const node of batch) {
const op = node.value
if (op.type === 'put') {
await b.put(op.key, op.value)
}
if (op.type === 'add-writer') {
await host.addWriter(Buffer.from(op.key, 'hex'), {
indexer: op.indexer !== false
})
}
}
await b.flush()
}
function createBase (store, key) {
return new Autobase(store.session(), key, {
open,
apply,
valueEncoding: 'json',
ackInterval: 0,
ackThreshold: 0
})
}
async function swarmReplicate (storeA, storeB, topic, testnet) {
const { bootstrap } = testnet
const swarmA = new Hyperswarm({ bootstrap })
const swarmB = new Hyperswarm({ bootstrap })
swarmA.on('connection', (conn) => storeA.replicate(conn))
swarmB.on('connection', (conn) => storeB.replicate(conn))
await swarmA.join(topic, { server: true, client: false }).flushed()
swarmB.join(topic, { client: true, server: false })
await swarmB.flush()
return { swarmA, swarmB }
}
async function waitForSync (bases, checkKey, maxWait = 10000) {
const start = Date.now()
while (Date.now() - start < maxWait) {
for (const b of bases) await b.update()
if (checkKey) {
let allHave = true
for (const b of bases) {
const entry = await b.view.get(checkKey)
if (!entry) { allHave = false; break }
}
if (allHave) return true
}
await new Promise(r => setTimeout(r, 300))
}
return false
}
// ─────────────────────────────────────────────────────────────
// Test 1: One-way replication over Hyperswarm (adoption only)
// ─────────────────────────────────────────────────────────────
test('one-way: server B reads server A issues via Hyperswarm', async (t) => {
t.timeout(30000)
const testnet = await createTestnet(3)
const dirA = tmpPath('a')
const dirB = tmpPath('b')
const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })
// Server A creates Autobase and writes issues
const baseA = createBase(storeA, null)
await baseA.ready()
const bootstrapKey = baseA.local.key
await baseA.append({ type: 'put', key: 'issue:1', value: { title: 'first issue' } })
await baseA.append({ type: 'put', key: 'issue:2', value: { title: 'second issue' } })
await baseA.update()
const check1 = await baseA.view.get('issue:1')
t.ok(check1, 'A has issue:1')
// Server B adopts with same bootstrap key (like _adoptIssueAutobase)
const baseB = createBase(storeB, bootstrapKey)
await baseB.ready()
// Connect via Hyperswarm
const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, baseA.discoveryKey, testnet)
// Wait for B to sync A's data
await waitForSync([baseA, baseB], 'issue:2')
const e1 = await baseB.view.get('issue:1')
const e2 = await baseB.view.get('issue:2')
t.ok(e1, 'B sees issue:1')
t.ok(e2, 'B sees issue:2')
t.is(e1.value.title, 'first issue')
t.is(e2.value.title, 'second issue')
// B is NOT writable (not added as writer)
t.is(baseB.writable, false, 'B is read-only')
await swarmA.destroy()
await swarmB.destroy()
await baseA.close()
await baseB.close()
await storeA.close()
await storeB.close()
await testnet.destroy()
await cleanup(dirA, dirB)
})
// ─────────────────────────────────────────────────────────────
// Test 2: Two-way multi-writer over Hyperswarm
// ─────────────────────────────────────────────────────────────
test('two-way: A adds B as writer, both write issues via Hyperswarm', async (t) => {
t.timeout(30000)
const testnet = await createTestnet(3)
const dirA = tmpPath('a')
const dirB = tmpPath('b')
const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })
// Server A creates Autobase
const baseA = createBase(storeA, null)
await baseA.ready()
const bootstrapKey = baseA.local.key
// Server B adopts
const baseB = createBase(storeB, bootstrapKey)
await baseB.ready()
t.comment('A local key: ' + baseA.local.key.toString('hex').slice(0, 16))
t.comment('B local key: ' + baseB.local.key.toString('hex').slice(0, 16))
// Connect via Hyperswarm
const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, baseA.discoveryKey, testnet)
// Wait for initial connection
await new Promise(r => setTimeout(r, 2000))
// A writes initial issue
await baseA.append({ type: 'put', key: 'issue:1', value: { title: 'from server A' } })
// A adds B as a writer — using B's ACTUAL local core key
// This is the critical part: must be base.local.key, not a server identity key
await baseA.append({
type: 'add-writer',
key: baseB.local.key.toString('hex'),
indexer: true
})
// Sync so B gets the add-writer op
await waitForSync([baseA, baseB], 'issue:1')
// Ack from both sides to confirm the writer set
if (baseA.writable) await baseA.append(null)
await new Promise(r => setTimeout(r, 1000))
for (const b of [baseA, baseB]) await b.update()
if (baseB.writable) await baseB.append(null)
await new Promise(r => setTimeout(r, 1000))
for (const b of [baseA, baseB]) await b.update()
t.comment('B writable after add-writer: ' + baseB.writable)
t.comment('A members: ' + baseA.system.members)
t.comment('B members: ' + baseB.system.members)
t.ok(baseB.writable, 'B is writable after being added')
t.is(baseA.system.members, 2, 'A sees 2 members')
// Now B writes an issue
if (baseB.writable) {
await baseB.append({ type: 'put', key: 'issue:2', value: { title: 'from server B' } })
}
// Sync again
await waitForSync([baseA, baseB], 'issue:2')
// Both should see both issues
const a1 = await baseA.view.get('issue:1')
const a2 = await baseA.view.get('issue:2')
const b1 = await baseB.view.get('issue:1')
const b2 = await baseB.view.get('issue:2')
t.ok(a1, 'A sees issue:1')
t.ok(b1, 'B sees issue:1')
t.is(a1.value.title, 'from server A')
t.is(b1.value.title, 'from server A')
if (baseB.writable) {
t.ok(a2, 'A sees issue:2 from B')
t.ok(b2, 'B sees issue:2 from B')
t.is(a2.value.title, 'from server B')
t.is(b2.value.title, 'from server B')
}
await swarmA.destroy()
await swarmB.destroy()
await baseA.close()
await baseB.close()
await storeA.close()
await storeB.close()
await testnet.destroy()
await cleanup(dirA, dirB)
})
// ─────────────────────────────────────────────────────────────
// Test 3: Wrong key type causes writable=false (the bug we hit)
// ─────────────────────────────────────────────────────────────
test('wrong key: using identity key instead of local.key fails gracefully', async (t) => {
t.timeout(30000)
const testnet = await createTestnet(3)
const dirA = tmpPath('a')
const dirB = tmpPath('b')
const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })
const baseA = createBase(storeA, null)
await baseA.ready()
const bootstrapKey = baseA.local.key
const baseB = createBase(storeB, bootstrapKey)
await baseB.ready()
const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, baseA.discoveryKey, testnet)
await new Promise(r => setTimeout(r, 2000))
// Simulate the bug: use a random key (like an Ed25519 identity key)
// instead of baseB.local.key
const fakeIdentityKey = Buffer.alloc(32, 0xf5).toString('hex')
t.comment('fake key: ' + fakeIdentityKey.slice(0, 16))
t.comment('correct key would be: ' + baseB.local.key.toString('hex').slice(0, 16))
await baseA.append({
type: 'add-writer',
key: fakeIdentityKey,
indexer: true
})
// Sync
for (let i = 0; i < 10; i++) {
for (const b of [baseA, baseB]) await b.update()
await new Promise(r => setTimeout(r, 300))
}
// B should still NOT be writable — wrong key was used
t.is(baseB.writable, false, 'B is NOT writable (wrong key used)')
// A should still have member count reflecting the bogus key
t.comment('A members: ' + baseA.system.members + ' (includes bogus)')
await swarmA.destroy()
await swarmB.destroy()
await baseA.close()
await baseB.close()
await storeA.close()
await storeB.close()
await testnet.destroy()
await cleanup(dirA, dirB)
})
// ─────────────────────────────────────────────────────────────
// Test 4: Correct key vs wrong key — side by side
// ─────────────────────────────────────────────────────────────
test('correct key = writable, wrong key = not writable', async (t) => {
t.timeout(15000)
// Correct: use base.local.key
const dirA = tmpPath('a')
const dirB = tmpPath('b')
const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })
const a = createBase(storeA, null)
await a.ready()
const b = createBase(storeB, a.local.key)
await b.ready()
// Add B with correct key
await a.append({ type: 'add-writer', key: b.local.key.toString('hex'), indexer: true })
// Pipe replicate (fast, no swarm needed for this test)
const s1 = a.replicate(true)
const s2 = b.replicate(false)
s1.pipe(s2).pipe(s1)
// Confirm
for (let i = 0; i < 3; i++) {
for (const base of [a, b]) await base.update()
if (a.writable) await a.append(null)
if (b.writable) await b.append(null)
await new Promise(r => setTimeout(r, 200))
for (const base of [a, b]) await base.update()
}
t.ok(b.writable, 'correct key → writable')
t.is(a.system.members, 2, '2 members')
s1.destroy()
s2.destroy()
await a.close()
await b.close()
await storeA.close()
await storeB.close()
// Wrong: use random key
const dirC = tmpPath('c')
const dirD = tmpPath('d')
const storeC = new Corestore(dirC, { primaryKey: Buffer.alloc(32, 2), unsafe: true })
const storeD = new Corestore(dirD, { primaryKey: Buffer.alloc(32, 3), unsafe: true })
const c = createBase(storeC, null)
await c.ready()
const d = createBase(storeD, c.local.key)
await d.ready()
// Add D with WRONG key
const wrongKey = Buffer.alloc(32, 0xab).toString('hex')
await c.append({ type: 'add-writer', key: wrongKey, indexer: true })
const s3 = c.replicate(true)
const s4 = d.replicate(false)
s3.pipe(s4).pipe(s3)
for (let i = 0; i < 3; i++) {
for (const base of [c, d]) await base.update()
await new Promise(r => setTimeout(r, 200))
}
t.is(d.writable, false, 'wrong key → NOT writable')
s3.destroy()
s4.destroy()
await c.close()
await d.close()
await storeC.close()
await storeD.close()
await cleanup(dirA, dirB, dirC, dirD)
})