| … | … | @@ -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 |
| 27 | import '../lib/bare-globals.js' |
| 28 | import test from 'brittle' |
| 29 | import fs from 'bare-fs' |
| 30 | import b4a from 'b4a' |
| 31 | import createTestnet from '@hyperswarm/testnet' |
| 32 | import { bootMultiDevice, provisionDevice } from '../lib/device-node.js' |
| 33 | import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js' |
| 34 | import { coownerInvite, coownerJoin } from '../lib/coowner.js' |
| 35 | import { loadConfig } from '../lib/config.js' |
| 36 | |
| 37 | 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' |
| 38 | 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' |
| 39 | |
| 40 | const tmp = (tag) => { const d = '/tmp/hpp-joinboot-' + tag + '-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 41 | const 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. |
| 44 | async 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. |
| 65 | async 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 | |
| 78 | test('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 | |
| 94 | test('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 | |
| 128 | test('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 | }) |