// A JOINED DID MUST ACTUALLY BOOT. Every other coowner test stops at the config.
//
// This is the seam that shipped broken (hyper-social #40 §3): the phone completed the pairing ceremony
// for real — roster grew 2 -> 3, bundle carried the signing key, the entry was written — and then served
// ZERO DIDs:
//
// {"msg":"multi-did: failed to boot DID","did":"did:plc:wj62…",
// "error":"bootSwarmDevice: deviceMnemonic (this device's own identity) required"}
// {"msg":"multi-did hpp serving","dids":0}
//
// The two halves of the mnemonic decision moved apart. `coownerJoin` made the device key DEVICE-scoped
// (coowner.js:183 writes `cfg.deviceMnemonic` at config ROOT) so a joiner can mint its key before it knows
// which DID it is joining — that is what removed the DID field from the pairing screen. It therefore
// writes a `dids` entry with NO `deviceMnemonic`. But `bootMultiDevice` passed each entry straight to
// `bootSwarmDevice`, which requires it ON THE ENTRY (device-node.js:115). The join half learned the new
// precedence; the boot half never did.
//
// ⛔ WHY EVERY EXISTING TEST MISSED IT — and why this file exists at all. `coowner-join-didless` asserts on
// the CONFIG; the adopt e2e asserts the written entry carries signingKey/joiner. Both stop one line before
// the failure. The one seam that changed is the one nothing crossed, so a green suite certified a node
// that could not start. A config assertion is not a boot assertion, and only the second kind would have
// caught this.
//
// So: run the REAL ceremony, load the config through the REAL loader, and BOOT it.
//
// Run: bare test/coowner-join-boots.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
import { coownerInvite, coownerJoin } from '../lib/coowner.js'
import { loadConfig } from '../lib/config.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'
const HOST_DEV = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote'
const tmp = (tag) => { const d = '/tmp/hpp-joinboot-' + tag + '-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))
// The HOST: a machine that already co-owns a DID, written the way a provisioned node's config looks.
async function hostConfig (dir) {
const dk = await deviceKeetKeyPair(HOST_DEV)
const b = await provisionDevice({ rootMnemonic: ROOT, devicePublicKey: dk.publicKey, handle: 'joinboot.test', pdsUrl: 'https://pds.local' })
const p = dir + '/config.json'
fs.writeFileSync(p, JSON.stringify({
storage: dir + '/data',
swarm: true,
dids: [{
did: b.did,
handle: 'joinboot.test',
deviceMnemonic: HOST_DEV,
deviceProof: b.deviceProof,
signingKey: b.signingKey,
rootIdentityKey: b.rootIdentityKey,
members: [b4a.toString(dk.publicKey, 'hex')]
}]
}, null, 2) + '\n')
return { configFile: p, did: b.did }
}
// The full ceremony, exactly as a phone runs it: mint a key with no DID, get a bundle back, write the entry.
async function ceremony () {
const host = await hostConfig(tmp('host'))
const joinerDir = tmp('joiner')
const joinerCfg = joinerDir + '/config.json'
fs.writeFileSync(joinerCfg, JSON.stringify({ storage: joinerDir + '/data', swarm: true, dids: [] }, null, 2) + '\n')
const pre = await coownerJoin({ configFile: joinerCfg }) // step 1: no DID — the phone does not know it yet
const { bundle } = await coownerInvite({ did: host.did, devicePublicKey: pre.devicePublicKey, configFile: host.configFile })
await coownerJoin({ bundle, configFile: joinerCfg }) // step 2: the entry is written from the bundle alone
return { did: host.did, joinerCfg, joinerDir, devicePublicKey: pre.devicePublicKey }
}
test('the entry a real join writes carries NO deviceMnemonic — the precondition for the bug', async (t) => {
const { did, joinerCfg, devicePublicKey } = await ceremony()
const cfg = readJson(joinerCfg)
const entry = cfg.dids.find((d) => d.did === did)
t.ok(entry, 'the DID entry was written')
t.ok(entry.signingKey && entry.deviceProof && entry.rootIdentityKey, 'and it is a complete bundle — this device really is a co-owner')
t.absent(entry.deviceMnemonic, 'but the mnemonic is NOT on the entry — it is device-scoped now')
t.ok(cfg.deviceMnemonic, 'it lives at config ROOT, where coownerJoin put it')
// Not a formality: it proves the root mnemonic is the key that was actually attested, so the fallback
// below hands boot the RIGHT identity rather than merely a present one.
const kp = await deviceKeetKeyPair(cfg.deviceMnemonic)
t.is(b4a.toString(kp.publicKey, 'hex'), devicePublicKey, 'and it derives the very key the host attested')
})
test('a joined DID BOOTS — the config loader and the daemon agree on where the mnemonic lives', { timeout: 300000 }, async (t) => {
const { did, joinerCfg } = await ceremony()
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
// Through the REAL loader — a hand-built dids array would skip config.js entirely, and the precedence
// being tested spans both files.
// Restored by assignment, never `delete` — Bare's process.env is a proxy whose deleteProperty trap
// returns false, and config.js's `pick` already treats '' as absent.
const prev = process.env.PEER_CONFIG || ''
process.env.PEER_CONFIG = joinerCfg
const cfg = loadConfig()
process.env.PEER_CONFIG = prev
t.is(cfg.dids.length, 1, 'the loader sees the joined DID')
t.ok(cfg.deviceMnemonic, 'and the device-scoped mnemonic')
const daemon = await bootMultiDevice({
storage: cfg.storage,
dids: cfg.dids,
deviceMnemonic: cfg.deviceMnemonic, // the fallback the join half has been writing all along
swarmOpts: { bootstrap: testnet.bootstrap },
signIntervalMs: 0 // no sign loop — this test is about booting, not signing
})
t.teardown(async () => { try { await daemon.close() } catch {} })
t.is(daemon.nodes.length, 1, 'the daemon SERVES the DID it just joined (0 here is the shipped bug)')
const node = daemon.byDid.get(did)
t.ok(node, 'and it is reachable by DID')
if (!node) return // the control path: report the failure above rather than throwing over it
t.is(node.did, did, 'booted the right identity')
t.is((await node.device.membersDiag()).amCreator, false, 'as a joiner — it did not claim the genesis')
})
test('an entry with its OWN mnemonic still wins over the device-scoped one', { timeout: 300000 }, async (t) => {
// The precedence must match coownerJoin's (per-DID first, else device-scoped). Inverting it would boot a
// co-owner under an unattested key — admitted by nobody, and silently so.
const { did, joinerCfg } = await ceremony()
const cfg = readJson(joinerCfg)
const perDid = cfg.deviceMnemonic // the attested one, moved onto the entry
cfg.dids[0].deviceMnemonic = perDid
cfg.deviceMnemonic = HOST_DEV // a DIFFERENT device-scoped key that must NOT be used
fs.writeFileSync(joinerCfg, JSON.stringify(cfg, null, 2) + '\n')
const testnet = await createTestnet(3)
t.teardown(() => testnet.destroy())
const daemon = await bootMultiDevice({
storage: cfg.storage,
dids: cfg.dids,
deviceMnemonic: cfg.deviceMnemonic,
swarmOpts: { bootstrap: testnet.bootstrap },
signIntervalMs: 0
})
t.teardown(async () => { try { await daemon.close() } catch {} })
const node = daemon.byDid.get(did)
t.ok(node, 'it booted')
const expected = b4a.toString((await deviceKeetKeyPair(perDid)).publicKey, 'hex')
t.is(node.self, expected, 'under the PER-DID mnemonic — the key that was attested, not the fallback')
})