// What a node can say about its own atproto identity — and what it refuses to do without.
//
// ⛔ The settings page cannot render Flow 1 without this. Before it, nothing anywhere could answer
// "does this node have a DID?", so the page had a text box where a state machine belonged.
//
// ⚠ The submit SUCCESS path is not tested here and deliberately so: it appends to the live global PLC
// directory, which is permanent and public, and a test identity would be forever. What IS tested is
// every refusal in front of it — which is where the real defect was, since `did-submit` used to call
// `submitMintedGenesis({ did })` without the `signedOp` it requires and could not have worked at all.
import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import process from 'bare-process'
import { join } from 'bare-path'
import { inProcessClient, ATPROTO_OPS, genesisPath, recoveryVaultPath } from '../index.js'
const PASS = 'a passphrase long enough'
function fixture (t, name) {
const root = join(os.tmpdir(), 'favela-host-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
const home = join(root, 'home')
const storage = join(root, 'storage')
fs.mkdirSync(home, { recursive: true })
fs.mkdirSync(storage, { recursive: true })
// $HOME redirected: a mint that took the desktop default would read — and could write — the real
// hyper-proto-peer config on this machine, which holds a live identity.
const realHome = process.env.HOME
process.env.HOME = home
t.teardown(() => { process.env.HOME = realHome })
return { storage, node: { storage }, client: inProcessClient({ storage }) }
}
test('a node with no identity says so, rather than failing', async (t) => {
const { client } = fixture(t, 'empty')
const st = await client.call('did-status', {})
t.is(st.did, null, 'no DID')
t.absent(st.sealed, 'nothing sealed')
t.absent(st.submitted, 'nothing submitted')
t.absent(st.publishable, 'and nothing to publish')
})
test('after minting it reports the DID, the vault, and that publishing is possible', async (t) => {
const { node, client } = fixture(t, 'minted')
const r = await client.call('did-mint', { passphrase: PASS })
const st = await client.call('did-status', {})
t.is(st.did, r.did, 'the DID it minted')
t.ok(st.sealed, 'the recovery vault is on disk')
t.ok(fs.existsSync(recoveryVaultPath(node, r.did)), 'where did-status says it is')
t.ok(st.publishable, 'the genesis operation was kept, so it CAN be published')
t.absent(st.submitted, 'and has not been — that is a separate, irreversible act')
})
test('the genesis is kept on disk, because nothing else keeps it', async (t) => {
const { node, client } = fixture(t, 'genesis')
const r = await client.call('did-mint', { passphrase: PASS })
t.ok(r.kept, 'the mint reports keeping it')
const g = JSON.parse(fs.readFileSync(genesisPath(node, r.did), 'utf8'))
t.is(g.did, r.did)
t.ok(g.signedOp, 'the operation submitMintedGenesis requires')
t.is(g.submittedAt, null, 'unsubmitted')
// hpp's own config does NOT carry it — which is why this file exists at all.
const cfg = JSON.parse(fs.readFileSync(join(node.storage, 'atproto', 'config.json'), 'utf8'))
t.absent(cfg.dids[0].signedOp, 'hpp keeps no copy, so dropping ours would make publishing impossible')
})
test('publishing refuses without an identity, and without a genesis to publish', async (t) => {
const { node, client } = fixture(t, 'refusals')
await t.exception(() => client.call('did-submit', {}), /no identity/)
const r = await client.call('did-mint', { passphrase: PASS })
// Remove the genesis to reach the second refusal. A real node loses it only by damage, and the
// message has to say what the way back is rather than throwing a missing-field error from inside.
fs.unlinkSync(genesisPath(node, r.did))
await t.exception(() => client.call('did-submit', {}), /not on disk/)
})
test('an already-submitted identity is not posted twice', async (t) => {
const { node, client } = fixture(t, 'already')
const r = await client.call('did-mint', { passphrase: PASS })
// Stand in for a successful submission. The directory is append-only, so a second post of the same
// genesis is at best waste — and reaching the network to prove that would be the very thing this
// test must not do.
const p = genesisPath(node, r.did)
const g = JSON.parse(fs.readFileSync(p, 'utf8'))
fs.writeFileSync(p, JSON.stringify({ ...g, submittedAt: Date.now() }, null, 2) + '\n')
const out = await client.call('did-submit', {})
t.ok(out.already, 'it reports the identity was already published')
t.ok(out.submitted)
t.ok((await client.call('did-status', {})).submitted, 'and status agrees')
})
// ⛔ Pinned so a verb cannot appear without a page that can reach it, or vanish from under one.
// `did-carry` is the one a person uses; `did-adopt` is its irreversible half, exposed separately only
// because a bridge that is already carrying you should not have to be asked twice.
//
// `bsky-project` is the one that AUTHORS. The other five are about the identity — minting it,
// publishing it, having a bridge carry it — and every one of them can succeed while the repo behind
// the identity stays empty, which is exactly what happened: a correct PLC record, a resolving handle,
// an active relay, and an atproto repo holding one bootstrap record. The fediverse needed no
// equivalent because ap-bridge projects from the feed it HOLDS; the keyless membrane cannot, so the
// device must author.
test('the verb set is exactly what the pages expect', (t) => {
t.alike(Object.keys(ATPROTO_OPS).sort(),
['bsky-project', 'did-adopt', 'did-carry', 'did-mint', 'did-status', 'did-submit'])
})