OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / mint.js

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