// MINTING AN IDENTITY ON THE DEVICE THAT WILL USE IT.
//
// Until now a node could only RECEIVE an identity — coownerJoin copies an existing DID onto a second
// device. That leaves the first device unexplained: something has to create the DID, and on a handset
// there is no CLI to run hpp-onboard from. So a second test phone was blocked on a step that existed
// only as a shell script on a server (hyper-social #54 §8).
//
// mintIdentity is hpp-onboard's fresh-mint path, writing the MULTI-DID `dids[]` entry a phone boots
// from instead of the single-DID server config. It mints P2P-ONLY: no handle, no PDS. Creating an
// identity and joining someone's infrastructure are separate decisions, and the domain handle arrives
// later as a PLC update op when an invite from atproto-bridge or ap-bridge is accepted.
//
// ⛔ THE LOAD-BEARING ASSERTION IS THE CUSTODY ONE. The root mnemonic is the recovery authority: it can
// rotate the DID's signing key and therefore revoke every device. device-node.js is explicit that a
// device receives { did, signingKey, deviceProof, rootIdentityKey } and NEVER the root, "so it can't
// derive the recovery key or self-recover". Minting on the device is the one moment the root exists
// there, so the test that matters is that it does not stay: it is returned to be shown once and sealed
// into the vault, and it is never written to the config.
//
// Run: bare test/mint.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import { checkGenesisOp } from 'atproto-identity/lib/plc-directory.js'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
import { mintIdentity } from '../lib/mint.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
function tmpCfg (name) {
const dir = path.join(os.tmpdir(), 'hpp-mint-' + name + '-' + process.pid)
fs.mkdirSync(dir, { recursive: true })
return path.join(dir, 'config.json')
}
test('mints a did:plc and writes a bootable dids[] entry', async (t) => {
const cfgFile = tmpCfg('basic')
const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile })
t.ok(r.did && r.did.startsWith('did:plc:'), 'a real did:plc — ' + r.did)
const cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8'))
t.is(cfg.dids.length, 1, 'one entry written')
const e = cfg.dids[0]
t.is(e.did, r.did, 'the entry names the minted DID')
t.ok(e.signingKey, 'carries the #atproto signing key — this device can author')
t.ok(e.deviceProof, 'carries the device attestation')
t.ok(e.rootIdentityKey, 'carries the root PUBLIC key (the admission anchor)')
t.ok(e.deviceMnemonic, 'carries its own device mnemonic')
})
// ⛔ THE BACKEND IS NOT OPTIONAL, AND ITS DEFAULT IS THE WRONG ONE.
//
// bootSwarmDevice defaults `storeBackend` to null, and `if (storeBackend === 'overlay')` is the ONLY
// thing that builds the OverlayRepoStore + record overlay. An entry without it boots on the LEGACY
// inline substrate: records live in the autobase where {$oid,size} pointers belong and there are no
// record files at all. Under PointerSubstrate that reads as an EMPTY record set, and device-node.js is
// explicit about what happens next — the first sign() after flipping such a DID to 'overlay' would
// "materialize an empty MST root and publish a delete-every-record commit" (the e2jev state,
// hyper-social#16 P1). The anti-wipe guard does not catch it, because that guard looks for record FILES
// and a legacy DID has none.
//
// Measured, not assumed: both live DIDs in the operator's config are storeBackend:'overlay', and a
// minted identity that omitted it logged "firehose-only (noRepo) DID" and could not post (2026-08-25).
//
// A freshly minted identity has no history to migrate, so starting on the current backend costs nothing
// and skips the one migration that can destroy a repo.
test('mints on the CURRENT record backend, not the legacy default', async (t) => {
const cfgFile = tmpCfg('backend')
await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile })
const e = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).dids[0]
t.is(e.storeBackend, 'overlay', 'storeBackend is overlay — the pointer substrate, matching every live DID')
})
test('⛔ the root mnemonic is returned, never written to disk', async (t) => {
const cfgFile = tmpCfg('custody')
const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile })
t.is(r.rootMnemonic, ROOT, 'returned to the caller — to show once and seal into the vault')
const raw = fs.readFileSync(cfgFile, 'utf8')
t.absent(raw.includes(ROOT), 'the root mnemonic is NOT in the config')
for (const word of ROOT.split(' ').slice(0, 3)) {
t.absent(new RegExp('"[^"]*' + word + ' ' + word).test(raw), 'no fragment of it either (' + word + ')')
}
const cfg = JSON.parse(raw)
t.absent(cfg.rootMnemonic, 'no rootMnemonic field')
t.absent(cfg.dids[0].rootMnemonic, 'not on the entry either')
t.not(cfg.dids[0].deviceMnemonic, ROOT, 'the device mnemonic is a DIFFERENT secret')
})
test('P2P-only: names nobody\'s infrastructure', async (t) => {
const cfgFile = tmpCfg('p2p')
const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile })
const e = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).dids[0]
t.absent(e.handle, 'no handle')
t.absent(e.pdsUrl, 'no PDS')
t.alike(r.signedOp.alsoKnownAs, [], 'and the op claims no alias')
t.alike(r.signedOp.services, {}, 'and names no service')
})
test('the genesis op it produces is submittable', async (t) => {
const r = await mintIdentity({ rootMnemonic: ROOT, configFile: tmpCfg('genesis') })
const chk = checkGenesisOp(r.signedOp, r.did)
t.ok(chk.ok, 'passes the directory\'s genesis validation: ' + (chk.error || 'ok'))
})
test('this device is the CREATOR, and is in its own roster', async (t) => {
const cfgFile = tmpCfg('creator')
const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile })
const e = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).dids[0]
// The first device holds the genesis. Getting this wrong is the loser-club fork: a node that does not
// claim the deterministic genesis writer authors a rival one (see coowner.js's `joiner` note).
t.ok(e.forceCreator, 'forceCreator — this node holds the genesis')
t.absent(e.joiner, 'and is NOT a joiner; exactly one node is the creator')
const kp = await deviceKeetKeyPair(e.deviceMnemonic)
const devHex = Buffer.from(kp.publicKey).toString('hex')
t.is(r.devicePublicKey, devHex, 'the reported device key derives from the written mnemonic')
t.ok((e.members || []).includes(devHex), 'and this device is in the roster it will be admitted against')
})
test('minting twice creates two DIFFERENT identities', async (t) => {
// No rootMnemonic passed — each mint generates its own. If it did not, every phone would mint the
// same DID and the second would silently collide with the first.
const a = await mintIdentity({ configFile: tmpCfg('rand-a') })
const b = await mintIdentity({ configFile: tmpCfg('rand-b') })
t.not(a.did, b.did, 'different DIDs')
t.not(a.rootMnemonic, b.rootMnemonic, 'different roots')
t.ok(a.rootMnemonic.split(' ').length >= 12, 'a real BIP39 mnemonic, not a placeholder')
})
test('the DID is reproducible from the root alone', async (t) => {
// This is what makes "submit the genesis later" possible: the op can be re-derived from the mnemonic
// the user wrote down, with no surviving local state. hpp-onboard's --submit mode relies on it.
const a = await mintIdentity({ rootMnemonic: ROOT, configFile: tmpCfg('det-a') })
const b = await mintIdentity({ rootMnemonic: ROOT, configFile: tmpCfg('det-b') })
t.is(a.did, b.did, 'same root => same DID')
})
test('refuses to clobber an identity this device already has', async (t) => {
const cfgFile = tmpCfg('clobber')
const first = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile })
await t.exception(mintIdentity({ configFile: cfgFile }), /already/i, 'a second mint is refused')
const cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8'))
t.is(cfg.dids.length, 1, 'and the existing entry is untouched')
t.is(cfg.dids[0].did, first.did, 'still the first identity')
})