OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 875abf7

0.3.110: a joined DID boots — bootMultiDevice reads the device-scoped mnemonic
The phone completed the co-owner ceremony for real (roster 2 -> 3, bundle carried the signing key, entry written) and then served ZERO DIDs: {"msg":"multi-did: failed to boot DID","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 knowing which DID it joins — which is what removed the DID field from the pairing screen. It therefore writes a `dids` entry with no deviceMnemonic. 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. bootMultiDevice now takes the device-scoped mnemonic and applies coownerJoin's own precedence — per-DID first (it may already be attested, and replacing it orphans the attestation), else device-scoped. index.js forwards cfg.deviceMnemonic, which config.js has always read and nothing ever passed on. test/coowner-join-boots.test.js: every existing coowner test asserts on the CONFIG and stops one line short of the failure, so a green suite certified a node that could not start. This one runs the real ceremony, loads through the real config loader, and BOOTS. Control run reproduces the production error verbatim; 13/13 after the fix. Neighbouring suites unchanged (join-role 20/20, coowner 26/26, didless 14/14, members-string 7/7, invite-noncreator 6/6, multi-did-daemon 12/12). Refs hyper-social org #40 §3 / §8 item 1-2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
875abf78263808b5e770d5c7009f1c84b9305347
by plankatron <fleeky@gmail.com> · 2026-08-10T13:44:47.000Z

Changed files (5) +171 -4

index.js +3 -1
……@@ -157,7 +157,9 @@ if (cfg.swarm) {
157157 console.log(JSON.stringify({ msg: 'ota: closing for respawn (exit 75)', version: ver || null }))
158158 Promise.resolve(daemon ? daemon.close() : null).catch(() => {}).then(() => process.exit(75)).catch(() => process.exit(75))
159159 }
160 daemon = await bootMultiDevice({ storage: cfg.storage, dids: cfg.dids, onRelease })
160 // cfg.deviceMnemonic is the DEVICE-scoped key `coowner-join` mints at config root for entries that
161 // carry none of their own (see bootMultiDevice). Config.js has always read it; nothing forwarded it.
162 daemon = await bootMultiDevice({ storage: cfg.storage, dids: cfg.dids, deviceMnemonic: cfg.deviceMnemonic, onRelease })
161163 console.log(JSON.stringify({ msg: 'multi-did daemon serving', dids: daemon.nodes.length, storage: cfg.storage }))
162164 const shutdownM = async () => { try { await daemon.close() } catch {} ; process.exit(0) }
163165 process.on('SIGINT', shutdownM)
lib/device-node.js +13 -2
……@@ -642,7 +642,16 @@ export function resyncDid (storage, did) {
642642// per-community scripts. Foundation proven in atproto-room/test/multi-did.test.js.
643643// dids = [{ deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, members,
644644// writers, adopt?, resetFirehose?, appPasswordHash?, jwtSecret? }].
645export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, signIntervalMs = 2000, onRelease = null } = {}) {
645//
646// `deviceMnemonic` (the top-level one) is the DEVICE-SCOPED fallback — this machine's own keet identity,
647// used by any entry that does not carry its own. A device key is derived from a mnemonic and NOTHING
648// else (`deviceKeetKeyPair` takes no DID), so `coownerJoin` mints it at config ROOT and writes a `dids`
649// entry WITHOUT one — that is what lets a phone mint its key before it knows which DID it is joining.
650// Boot must read it the same way or a genuinely-joined DID dies on "deviceMnemonic required" and the
651// daemon serves ZERO DIDs while its config, roster and attestation are all perfect (hyper-social #40 §3).
652// Precedence is coownerJoin's, exactly: per-DID first (it may already be attested, and replacing it
653// orphans the attestation), else device-scoped. Proof: test/coowner-join-boots.test.js.
654export async function bootMultiDevice ({ storage, dids = [], deviceMnemonic = null, swarmOpts = null, signIntervalMs = 2000, onRelease = null } = {}) {
646655 if (typeof storage !== 'string') throw new Error('bootMultiDevice: storage path required')
647656 if (!Array.isArray(dids) || dids.length === 0) throw new Error('bootMultiDevice: at least one DID required')
648657
……@@ -721,7 +730,9 @@ export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, s
721730 if (d.adoptCar && !d.adopt) {
722731 try { entry = { ...d, adopt: await readAdoptRepo(fs.readFileSync(d.adoptCar)) }; console.log(JSON.stringify({ msg: 'multi-did: adoptCar loaded for rebuild', did: d.did, car: d.adoptCar, records: entry.adopt.records.length, rev: entry.adopt.head.rev })) } catch (e) { console.error(JSON.stringify({ msg: 'multi-did: adoptCar load FAILED', did: d.did, car: d.adoptCar, error: e.message })) }
723732 }
724 const node = await bootSwarmDevice({ storage, ...entry, swarm: true, swarmOpts, signIntervalMs, shared, resyncStore, onRelease })
733 // deviceMnemonic AFTER the spread, and explicit rather than relying on spread order: an entry may
734 // carry the key with an undefined value, which would silently win and re-throw the original error.
735 const node = await bootSwarmDevice({ storage, ...entry, deviceMnemonic: entry.deviceMnemonic || deviceMnemonic, swarm: true, swarmOpts, signIntervalMs, shared, resyncStore, onRelease })
725736 swarm.join(node.sb.base.discoveryKey, { server: true, client: true }) // daemon joins each DID's StateBase topic
726737 // resync side store: replicate on connections already open (future ones via the connection handler).
727738 if (node._sideStore) for (const conn of swarm.connections) { try { node._sideStore.replicate(conn) } catch {} }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.109",
3 "version": "0.3.110",
44 "description": "Self-custody, P2P-native atproto peer — holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
test/all.js +1 -0
……@@ -25,6 +25,7 @@ import './reconcile-bytes.test.mjs'
2525import './pointer-diag.test.mjs'
2626import './coowner.test.mjs'
2727import './coowner-join-role.test.js'
28import './coowner-join-boots.test.js'
2829import './joiner-resync-2node.test.js'
2930import './joiner-resync-multidid.test.js'
3031import './joiner-resync-overlay.test.js'
test/coowner-join-boots.test.js +153 -0
……@@ -0,0 +1,153 @@
1// A JOINED DID MUST ACTUALLY BOOT. Every other coowner test stops at the config.
2//
3// This is the seam that shipped broken (hyper-social #40 §3): the phone completed the pairing ceremony
4// for real — roster grew 2 -> 3, bundle carried the signing key, the entry was written — and then served
5// ZERO DIDs:
6//
7// {"msg":"multi-did: failed to boot DID","did":"did:plc:wj62…",
8// "error":"bootSwarmDevice: deviceMnemonic (this device's own identity) required"}
9// {"msg":"multi-did hpp serving","dids":0}
10//
11// The two halves of the mnemonic decision moved apart. `coownerJoin` made the device key DEVICE-scoped
12// (coowner.js:183 writes `cfg.deviceMnemonic` at config ROOT) so a joiner can mint its key before it knows
13// which DID it is joining — that is what removed the DID field from the pairing screen. It therefore
14// writes a `dids` entry with NO `deviceMnemonic`. But `bootMultiDevice` passed each entry straight to
15// `bootSwarmDevice`, which requires it ON THE ENTRY (device-node.js:115). The join half learned the new
16// precedence; the boot half never did.
17//
18// ⛔ WHY EVERY EXISTING TEST MISSED IT — and why this file exists at all. `coowner-join-didless` asserts on
19// the CONFIG; the adopt e2e asserts the written entry carries signingKey/joiner. Both stop one line before
20// the failure. The one seam that changed is the one nothing crossed, so a green suite certified a node
21// that could not start. A config assertion is not a boot assertion, and only the second kind would have
22// caught this.
23//
24// So: run the REAL ceremony, load the config through the REAL loader, and BOOT it.
25//
26// Run: bare test/coowner-join-boots.test.js
27import '../lib/bare-globals.js'
28import test from 'brittle'
29import fs from 'bare-fs'
30import b4a from 'b4a'
31import createTestnet from '@hyperswarm/testnet'
32import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
33import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
34import { coownerInvite, coownerJoin } from '../lib/coowner.js'
35import { loadConfig } from '../lib/config.js'
36
37const 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'
38const 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'
39
40const tmp = (tag) => { const d = '/tmp/hpp-joinboot-' + tag + '-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
41const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))
42
43// The HOST: a machine that already co-owns a DID, written the way a provisioned node's config looks.
44async function hostConfig (dir) {
45 const dk = await deviceKeetKeyPair(HOST_DEV)
46 const b = await provisionDevice({ rootMnemonic: ROOT, devicePublicKey: dk.publicKey, handle: 'joinboot.test', pdsUrl: 'https://pds.local' })
47 const p = dir + '/config.json'
48 fs.writeFileSync(p, JSON.stringify({
49 storage: dir + '/data',
50 swarm: true,
51 dids: [{
52 did: b.did,
53 handle: 'joinboot.test',
54 deviceMnemonic: HOST_DEV,
55 deviceProof: b.deviceProof,
56 signingKey: b.signingKey,
57 rootIdentityKey: b.rootIdentityKey,
58 members: [b4a.toString(dk.publicKey, 'hex')]
59 }]
60 }, null, 2) + '\n')
61 return { configFile: p, did: b.did }
62}
63
64// The full ceremony, exactly as a phone runs it: mint a key with no DID, get a bundle back, write the entry.
65async function ceremony () {
66 const host = await hostConfig(tmp('host'))
67 const joinerDir = tmp('joiner')
68 const joinerCfg = joinerDir + '/config.json'
69 fs.writeFileSync(joinerCfg, JSON.stringify({ storage: joinerDir + '/data', swarm: true, dids: [] }, null, 2) + '\n')
70
71 const pre = await coownerJoin({ configFile: joinerCfg }) // step 1: no DID — the phone does not know it yet
72 const { bundle } = await coownerInvite({ did: host.did, devicePublicKey: pre.devicePublicKey, configFile: host.configFile })
73 await coownerJoin({ bundle, configFile: joinerCfg }) // step 2: the entry is written from the bundle alone
74
75 return { did: host.did, joinerCfg, joinerDir, devicePublicKey: pre.devicePublicKey }
76}
77
78test('the entry a real join writes carries NO deviceMnemonic — the precondition for the bug', async (t) => {
79 const { did, joinerCfg, devicePublicKey } = await ceremony()
80 const cfg = readJson(joinerCfg)
81 const entry = cfg.dids.find((d) => d.did === did)
82
83 t.ok(entry, 'the DID entry was written')
84 t.ok(entry.signingKey && entry.deviceProof && entry.rootIdentityKey, 'and it is a complete bundle — this device really is a co-owner')
85 t.absent(entry.deviceMnemonic, 'but the mnemonic is NOT on the entry — it is device-scoped now')
86 t.ok(cfg.deviceMnemonic, 'it lives at config ROOT, where coownerJoin put it')
87
88 // Not a formality: it proves the root mnemonic is the key that was actually attested, so the fallback
89 // below hands boot the RIGHT identity rather than merely a present one.
90 const kp = await deviceKeetKeyPair(cfg.deviceMnemonic)
91 t.is(b4a.toString(kp.publicKey, 'hex'), devicePublicKey, 'and it derives the very key the host attested')
92})
93
94test('a joined DID BOOTS — the config loader and the daemon agree on where the mnemonic lives', { timeout: 300000 }, async (t) => {
95 const { did, joinerCfg } = await ceremony()
96 const testnet = await createTestnet(3)
97 t.teardown(() => testnet.destroy())
98
99 // Through the REAL loader — a hand-built dids array would skip config.js entirely, and the precedence
100 // being tested spans both files.
101 // Restored by assignment, never `delete` — Bare's process.env is a proxy whose deleteProperty trap
102 // returns false, and config.js's `pick` already treats '' as absent.
103 const prev = process.env.PEER_CONFIG || ''
104 process.env.PEER_CONFIG = joinerCfg
105 const cfg = loadConfig()
106 process.env.PEER_CONFIG = prev
107
108 t.is(cfg.dids.length, 1, 'the loader sees the joined DID')
109 t.ok(cfg.deviceMnemonic, 'and the device-scoped mnemonic')
110
111 const daemon = await bootMultiDevice({
112 storage: cfg.storage,
113 dids: cfg.dids,
114 deviceMnemonic: cfg.deviceMnemonic, // the fallback the join half has been writing all along
115 swarmOpts: { bootstrap: testnet.bootstrap },
116 signIntervalMs: 0 // no sign loop — this test is about booting, not signing
117 })
118 t.teardown(async () => { try { await daemon.close() } catch {} })
119
120 t.is(daemon.nodes.length, 1, 'the daemon SERVES the DID it just joined (0 here is the shipped bug)')
121 const node = daemon.byDid.get(did)
122 t.ok(node, 'and it is reachable by DID')
123 if (!node) return // the control path: report the failure above rather than throwing over it
124 t.is(node.did, did, 'booted the right identity')
125 t.is((await node.device.membersDiag()).amCreator, false, 'as a joiner — it did not claim the genesis')
126})
127
128test('an entry with its OWN mnemonic still wins over the device-scoped one', { timeout: 300000 }, async (t) => {
129 // The precedence must match coownerJoin's (per-DID first, else device-scoped). Inverting it would boot a
130 // co-owner under an unattested key — admitted by nobody, and silently so.
131 const { did, joinerCfg } = await ceremony()
132 const cfg = readJson(joinerCfg)
133 const perDid = cfg.deviceMnemonic // the attested one, moved onto the entry
134 cfg.dids[0].deviceMnemonic = perDid
135 cfg.deviceMnemonic = HOST_DEV // a DIFFERENT device-scoped key that must NOT be used
136 fs.writeFileSync(joinerCfg, JSON.stringify(cfg, null, 2) + '\n')
137
138 const testnet = await createTestnet(3)
139 t.teardown(() => testnet.destroy())
140 const daemon = await bootMultiDevice({
141 storage: cfg.storage,
142 dids: cfg.dids,
143 deviceMnemonic: cfg.deviceMnemonic,
144 swarmOpts: { bootstrap: testnet.bootstrap },
145 signIntervalMs: 0
146 })
147 t.teardown(async () => { try { await daemon.close() } catch {} })
148
149 const node = daemon.byDid.get(did)
150 t.ok(node, 'it booted')
151 const expected = b4a.toString((await deviceKeetKeyPair(perDid)).publicKey, 'hex')
152 t.is(node.self, expected, 'under the PER-DID mnemonic — the key that was attested, not the fallback')
153})