OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / coowner-invite-noncreator.test.mjs

86 lines · javascript ref:
// 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')
})