// MINT — create a NEW atproto identity on the device that will use it.
//
// The symmetric half of lib/coowner.js. coownerJoin copies an EXISTING DID onto a second device; this
// creates the first one. Without it a node could only ever receive an identity, which left the origin
// of every identity outside the product: `bin/hpp-onboard.js` on a server with a hyperident and a shell.
// A handset has neither, so a second test phone was blocked on a step that could not be performed on it
// (hyper-social #54 §8).
//
// This is hpp-onboard's fresh-mint path — the same createSwarmIdentity + provisionDevice — writing the
// MULTI-DID `dids[]` entry that bootMultiDevice reads, rather than the single-DID server config.
//
// ── P2P-ONLY BY DEFAULT ──────────────────────────────────────────────────────────────────────────
// No handle, no PDS. Creating an identity and joining someone's infrastructure are different decisions,
// and requiring both at once is what made minting undecidable — you could not have a DID without first
// answering "whose server are you on?". The domain handle and the service endpoint arrive LATER, as a
// PLC update op, when an invite from atproto-bridge or ap-bridge is accepted. That ordering is forced,
// not chosen: an update op must chain onto a genesis already in the log.
//
// ── ⛔ CUSTODY: THE ROOT MNEMONIC MUST NOT STAY HERE ──────────────────────────────────────────────
// The root 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 gets { 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 it is RETURNED and never written: the caller shows it once
// and seals it into the vault (atproto-identity/lib/vault.js), and nothing persists it in the config.
// test/mint.test.mjs asserts the config does not contain it.
//
// ── WHAT THIS DOES NOT DO ────────────────────────────────────────────────────────────────────────
// It does not touch the network. Submitting the genesis to the PLC directory is a PERMANENT, PUBLIC,
// IRREVERSIBLE append to a global log, and it is a separate call for that reason — the same split
// hpp-onboard makes with --submit. A mint that cannot reach the directory still produces a working P2P
// identity, and the op can be re-derived from the mnemonic and submitted later (see the determinism
// test): submission is recoverable, so it must not be able to fail the part that isn't.
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
import { provisionDevice } from './device-node.js'
import { readConfigFile, writeConfigFile } from './coowner.js'
import { configPath } from './config.js'
const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
/**
* Create a new identity and write the dids[] entry this device boots from.
*
* @param {object} opts
* @param {string} [opts.rootMnemonic] the recovery root. Omitted, a fresh one is generated — the normal
* case. Passing one is for tests and for re-minting a known identity.
* @param {string} [opts.configFile] defaults to the usual PEER_CONFIG / ~/.config path.
* @param {string|null} [opts.handle] leave null for P2P-only. Naming one here mints a DIFFERENT DID.
* @param {string|null} [opts.pdsUrl] as above.
* @param {function} [opts.onStep] progress, mirroring joinAdoptInvite's reporting.
* @returns {{did, rootMnemonic, devicePublicKey, signedOp, entry}}
*/
export async function mintIdentity ({ rootMnemonic = null, configFile = null, handle = null, pdsUrl = null, onStep = () => {} } = {}) {
const p = configFile || configPath()
const cfg = readConfigFile(p)
if (!Array.isArray(cfg.dids)) cfg.dids = []
// ⛔ REFUSE RATHER THAN ADD. A device that already holds an identity is not asking to mint a second
// one — it has arrived here by a routing mistake or a double-tap, and the cost of being wrong is
// asymmetric: minting is cheap to repeat and impossible to undo, and a stray second entry would have
// this node signing as an identity nobody else in the room knows about.
if (cfg.dids.length) {
throw new Error('mint: this device already has an identity (' + cfg.dids[0].did + ') — refusing to mint another')
}
const root = rootMnemonic || IdentityKey.generateMnemonic()
onStep('generating this device\'s key')
// DEVICE-SCOPED, and a different secret from the root. The device key is what the roster admits; the
// root is what attests it. Conflating them would put the recovery authority on every device.
const deviceMnemonic = IdentityKey.generateMnemonic()
const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
const devicePublicKey = hex(deviceKp.publicKey)
onStep('creating the identity')
const bundle = await provisionDevice({ rootMnemonic: root, devicePublicKey, handle, pdsUrl })
const entry = {
did: bundle.did,
deviceMnemonic,
deviceProof: bundle.deviceProof,
signingKey: bundle.signingKey,
rootIdentityKey: bundle.rootIdentityKey,
// The roster this device will be admitted against. It contains itself and nothing else: it is the
// only device that exists. Co-owners are added later by the invite ceremony.
members: [devicePublicKey],
// ⛔ EXPLICIT, BECAUSE THE DEFAULT IS THE LEGACY BACKEND. bootSwarmDevice defaults storeBackend to
// null and `if (storeBackend === 'overlay')` is the only thing that builds the OverlayRepoStore and
// record overlay. Omitting it boots the DID on the legacy inline substrate — records in the autobase
// where {$oid,size} pointers belong, and no record files — which reads as an EMPTY record set under
// PointerSubstrate. device-node.js spells out the consequence: the first sign() after flipping such
// a DID to 'overlay' materializes an empty MST root and publishes a delete-every-record commit, and
// the anti-wipe guard cannot catch it because that guard looks for record FILES and there are none.
//
// Every live DID in the fleet is 'overlay'. A minted identity that omitted this logged
// "firehose-only (noRepo) DID" and silently could not post — observed on a handset 2026-08-25. A
// fresh identity has no history to migrate, so starting on the current backend is free and skips
// the one migration that can destroy a repo.
storeBackend: 'overlay',
// THE FIRST DEVICE HOLDS THE GENESIS. This is the opposite posture from coownerJoin's `joiner`, and
// it is explicit for the reason recorded there: the creator election is "lowest-keyed member ==
// self", so leaving the role to emerge from key ordering silently flips it when a device joins whose
// key sorts lower. A room's creator is a property, not an accident.
forceCreator: true
}
// handle / pdsUrl are deliberately ABSENT rather than null — an entry that says nothing about
// infrastructure is different from one that says "none", and the invite will add the real values.
if (handle) entry.handle = handle
if (pdsUrl) entry.pdsUrl = pdsUrl
cfg.dids.push(entry)
writeConfigFile(p, cfg)
onStep('identity written — restart to sign')
return {
did: bundle.did,
// ⛔ SHOW ONCE, SEAL, FORGET. Never persisted here. See the custody note above.
rootMnemonic: root,
devicePublicKey,
signedOp: bundle.signedOp,
entry: { did: bundle.did, members: entry.members }
}
}
/**
* Submit a minted genesis to the PLC directory.
*
* ⛔ PERMANENT AND PUBLIC. This appends to a global append-only log. There is no delete, and a test
* identity is forever. Kept separate from mintIdentity so that the irreversible act is always its own
* explicit call, and so a mint without network still succeeds.
*
* Safe to retry: the directory accepts a re-submission of the identical genesis, and the op can be
* re-derived from the root mnemonic alone if this device is gone.
*/
/**
* Install a global `fetch`, from THIS package's dependency tree.
*
* ⛔ WHY IT LIVES HERE AND NOT IN THE CALLER. `bare-fetch` pulls in bare-tls and bare-zlib, which are
* NATIVE. An Android bundle names the sonames it needs and the APK must supply them — and the app's
* own tree resolves bare-tls 3.1.10 while the APK carries 3.1.7. A caller importing bare-fetch itself
* therefore demands a library that does not exist on the device: measured, as a failed build, and the
* failure mode had it shipped is a bundle that dies at dlopen and is condemned for ever.
*
* Resolving it from here keeps the version the same one every other network path in this package
* already uses, which is the one the APK was built against.
*/
export async function ensureFetch () {
if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
return typeof globalThis.fetch === 'function'
}
export async function submitMintedGenesis ({ did, signedOp, plcUrl = null, onStep = () => {} } = {}) {
if (!did || !signedOp) throw new Error('submitMintedGenesis requires { did, signedOp }')
// Bare has no global fetch and PlcDirectory needs one for the POST. Installed lazily so that minting
// — which is offline — never drags the network stack in. Same approach as hpp-onboard.
if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
onStep('registering in the global directory')
const plc = new PlcDirectory(plcUrl ? { baseUrl: plcUrl } : {})
const data = await plc.submitGenesis({ did, signedOp })
onStep('registered')
return { did, submitted: true, alsoKnownAs: (data && data.alsoKnownAs) || [], services: (data && data.services) || {} }
}