// CONTROL: inviting a device must not change WHO THE CREATOR IS.
//
// Any co-owner can vouch for the next device — the header of lib/coowner.js says so, and the chained
// attestation (root -> us -> joiner) supports it. But `coownerInvite` sets `forceCreator = true` on
// whichever machine mints, gated only on `!entry.joiner`. That is wrong for two real cases:
//
// 1. A node deliberately forced NON-creator. tabertron is exactly this for wj62: its non-creator
// posture comes from `resyncStore` (device-node.js:178 `forceNonCreator: !!resyncStore || !!joiner`),
// NOT from the `joiner` flag — so the `!entry.joiner` guard does not see it. Minting an invite
// there would write `forceCreator: true`, and state-base.js:406 checks `_forceCreator` BEFORE
// `_forceNonCreator`, so the pin WINS: wj62 ends up with two creators, which is the loser-club
// fork the flag exists to prevent.
//
// 2. The phone. favela-android is a joiner that must be able to invite the NEXT device (keet's model:
// any device holding the identity can pair another). It is `joiner: true` so it is covered today —
// but only by accident of that one flag, not by intent.
//
// The pin itself is correct and must stay: growing the roster can silently demote the incumbent, since
// the election is `lowest-keyed member == self`. What must change is that it only ever pins a node that
// is ALREADY the creator, and never contradicts an explicit role.
//
// Run: bare test/coowner-invite-noncreator.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
import { coownerInvite } from '../lib/coowner.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DID = 'did:plc:coowner00000000000000000'
const SIGNING = 'a'.repeat(64)
const JOINER = 'd'.repeat(64)
const tmpdir = () => { const d = '/tmp/hpp-noncreator-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))
async function hostConfig (dir, extra = {}) {
const dev = await deviceKeetKeyPair(HOST_DEV)
const proof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: dev.publicKey })
const p = dir + '/config.json'
fs.writeFileSync(p, JSON.stringify({
storage: dir + '/data',
swarm: true,
dids: [{
did: DID,
handle: 'coowner.test',
deviceMnemonic: HOST_DEV,
deviceProof: hex(proof),
signingKey: SIGNING,
rootIdentityKey: hex(await rootIdentityPublicKey(ROOT)),
members: [hex(dev.publicKey)],
...extra
}]
}, null, 2))
return p
}
test('CONTROL — a node that is NOT the creator does not become one by inviting', async (t) => {
// `resyncStore` is a runtime flag, so the durable marker of "this node must not be creator" in a
// config is an explicit forceCreator:false. It must be honoured, not overwritten.
const p = await hostConfig(tmpdir(), { forceCreator: false })
await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: p })
const entry = readJson(p).dids[0]
t.is(entry.forceCreator, false, 'an explicit forceCreator:false SURVIVES the invite — minting is not a claim to the genesis')
t.ok((Array.isArray(entry.members) ? entry.members : []).includes(JOINER), 'and the device was still admitted')
})
test('a JOINER can invite the next device without claiming the genesis', async (t) => {
const p = await hostConfig(tmpdir(), { joiner: true })
await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: p })
const entry = readJson(p).dids[0]
t.absent(entry.forceCreator, 'a joiner never pins itself creator')
t.is(entry.joiner, true, 'and stays a joiner')
t.ok((Array.isArray(entry.members) ? entry.members : []).includes(JOINER), 'the invite still worked — this is keet\'s model, any device can pair the next')
})
test('an unflagged incumbent STILL pins itself — the demote guard must not regress', async (t) => {
const p = await hostConfig(tmpdir())
await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: p })
const entry = readJson(p).dids[0]
t.is(entry.forceCreator, true, 'growing the roster can silently demote the incumbent, so it pins itself')
})