OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 39ff6dc

0.3.122: hosting-show / hosting-attach / hosting-detach — the invite ceremony from a terminal
atproto-identity/lib/hosting.js is the pure half. This is the ceremony around it: find the current head, get the recovery key out of wherever the holder keeps it, and SHOW exactly what is about to change before anything permanent happens. ⛔ PREVIEW IS THE DEFAULT; --yes is the only way past it. A PLC append cannot be undone, edited or hidden, so "write to a global public log" is the wrong default for a person AND for the web UI this is shaped for, where the same button gets double-clicked. Preview -> confirm is what both want, and the UI reuses the same functions rather than reimplementing the preview a human approves. ⛔ THE MNEMONIC IS NEVER AN ARGUMENT. argv is visible in ps to every user on the box and lands in shell history. It is read from a file, or from the sealed vault `mint` already writes on the handset — the path that matters, because there the secret was never typed anywhere. The preview ASSERTS that rotation keys and the signing key are carried forward, rather than assuming it. A PLC op replaces the whole document, so "unchanged" is a property of one op and not a law, and silently dropping a rotation key is the mistake nobody recovers from. The test tampers with an op to prove the assertion actually notices. Verified live against did:plc:zl5loetx46p6xusgc47h3mia — handle null, pds null, one rotation key, read from the public directory. That is the P2P-only genesis, confirmed rather than claimed. After a successful attach the CLI prints what it CANNOT do: adding the DID to the bridge's frontedDids is the operator's half. Neither side can complete the ceremony alone, which is the property that makes hosting a service instead of custody. 8 tests, 20 assertions, offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39ff6dceed2a59f403bcaabe3bbd203b2de28318
by plankatron <fleeky@gmail.com> · 2026-08-26T01:44:18.000Z

Changed files (5) +296 -2

index.js +10 -0
……@@ -71,6 +71,16 @@ if (sub === 'repair-pointers') {
7171 const { repairPointersCommand } = await import('./lib/repair-pointers.js')
7272 globalThis.Bare.exit(await repairPointersCommand(argv.slice(1)))
7373}
74// hosting-show / hosting-attach / hosting-detach — accept a bridge's invite, or leave.
75//
76// Genesis is P2P-only, so an identity exists without anyone's permission; being FRONTED by a membrane is
77// what an invite grants. Accepting one is a PLC update adding a handle and a PDS endpoint, and it can
78// only be signed by a current rotation key — so the operator cannot do it for you, and you cannot front
79// yourself. Preview by default; --yes appends.
80if (sub === 'hosting-show' || sub === 'hosting-attach' || sub === 'hosting-detach') {
81 const { hostingCommand } = await import('./lib/hosting-cli.js')
82 globalThis.Bare.exit(await hostingCommand(argv))
83}
7484// coowner-invite / coowner-join — admit another machine as a co-owner of a DID. A COMMAND, because doing
7585// it by hand is four hex-string edits across two boxes with a shared secret in the middle, and it is what
7686// anyone adding a second machine needs. No root mnemonic involved: the invite is chain-attested by the
lib/hosting-cli.js +188 -0
……@@ -0,0 +1,188 @@
1// The invite ceremony, driven from a terminal — and shaped so a bridge web UI can drive the same thing.
2//
3// atproto-identity/lib/hosting.js is the pure half: given the head op it returns a signed update that
4// claims a handle and names a PDS. This adds the three things a real ceremony needs around it — find the
5// current head, get the recovery key out of wherever it lives, and SHOW what is about to happen before
6// anything permanent occurs.
7//
8// ⛔ THE SUBMIT IS THE IRREVERSIBLE HALF, SO IT IS OPT-IN. Every function here previews by default and
9// only submits when explicitly told to. A PLC append cannot be undone, edited or hidden; a ceremony whose
10// default action is "write to a global public log" is the wrong default for a person AND for a web UI,
11// where the same button gets double-clicked. Preview → confirm is the shape both want.
12//
13// ⛔ THE MNEMONIC IS NEVER AN ARGUMENT. Anything in argv is in `ps` for every user on the box, and in
14// shell history. It comes from a FILE, or from the sealed vault the phone already produces — which is
15// the path that matters, because it is the one where the secret was never typed anywhere.
16//
17// WHAT A WEB UI WOULD REUSE: describeChange (the preview it renders), resolveSigner (the unlock step),
18// and hosting.js's attach/detach. What it would NOT reuse is the operator half — adding the DID to the
19// bridge's frontedDids — because that is the OTHER side of the ceremony and the whole point is that
20// neither side can complete it alone.
21
22import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
23import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
24import { openVault } from 'atproto-identity/lib/vault.js'
25import { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js'
26
27/**
28 * A human-readable account of what an op changes about a DID document.
29 *
30 * Pure, so the preview a person approves is computed the same way whatever renders it — a terminal now,
31 * a bridge web UI later. Reporting only what CHANGES is deliberate: a full document dump buries the one
32 * line that matters, and this is the moment someone decides whether to append to a permanent log.
33 */
34export function describeChange (prevSignedOp, nextOp) {
35 const before = prevSignedOp || {}
36 const akaOf = (o) => ((o && o.alsoKnownAs) || []).join(', ') || '(none)'
37 const pdsOf = (o) => (o && o.services && o.services.atproto_pds && o.services.atproto_pds.endpoint) || '(none)'
38 const changes = []
39 if (akaOf(before) !== akaOf(nextOp)) changes.push({ field: 'handle', from: akaOf(before), to: akaOf(nextOp) })
40 if (pdsOf(before) !== pdsOf(nextOp)) changes.push({ field: 'pds', from: pdsOf(before), to: pdsOf(nextOp) })
41
42 // Carried-forward fields are worth ASSERTING in the preview rather than assuming. A PLC op replaces the
43 // whole document, so "unchanged" is a property of this particular op, not a law — and silently dropping
44 // a rotation key is the one mistake nobody recovers from.
45 const kept = {
46 rotationKeys: JSON.stringify(before.rotationKeys) === JSON.stringify(nextOp.rotationKeys),
47 verificationMethods: JSON.stringify(before.verificationMethods) === JSON.stringify(nextOp.verificationMethods)
48 }
49 return { changes, kept, prev: nextOp.prev }
50}
51
52/**
53 * Get the recovery private key from wherever the holder keeps it.
54 *
55 * Two sources, and the vault is the one that matters: it is what `mint` seals on the phone, so the
56 * secret can travel to another device without ever being typed. The mnemonic file exists because paper
57 * is the other real backup and someone recovering from it has nothing else.
58 *
59 * ⛔ NOT FROM argv. See the header — argv is world-readable in ps.
60 */
61export function resolveSigner ({ mnemonic = null, vault = null, passphrase = null, did = null } = {}) {
62 let phrase = mnemonic
63 if (!phrase && vault) {
64 if (!passphrase) throw new Error('hosting: a sealed vault needs its passphrase')
65 if (!did) throw new Error('hosting: opening a vault needs the DID it was sealed for')
66 // openVault fails identically for a wrong passphrase, a tampered blob and the wrong identity — by
67 // design, so a failure does not tell an attacker which half they got right.
68 phrase = openVault({ envelope: vault, passphrase, did })
69 }
70 if (!phrase) throw new Error('hosting: no recovery mnemonic — pass --mnemonic-file or --vault')
71 const key = deriveAtprotoKey(String(phrase).trim(), ATPROTO_RECOVERY_PATH)
72 return { privateKey: key.privateKey, didKey: key.didKey }
73}
74
75function directory (plcUrl) {
76 return new PlcDirectory(plcUrl ? { baseUrl: plcUrl } : {})
77}
78
79/** What the directory currently says about this identity. */
80export async function hostingShow ({ did, plcUrl = null } = {}) {
81 if (!did) throw new Error('hostingShow: did required')
82 const head = await directory(plcUrl).getHead(did)
83 return {
84 did,
85 handle: ((head && head.alsoKnownAs) || [])[0] || null,
86 pds: (head && head.services && head.services.atproto_pds && head.services.atproto_pds.endpoint) || null,
87 rotationKeys: (head && head.rotationKeys) || [],
88 head
89 }
90}
91
92/**
93 * Accept an invite: claim `handle`, name `pdsUrl`.
94 *
95 * Previews unless `submit` is true. Returns the signed op either way, so a caller that wants to show it,
96 * store it, or hand it to a person for a second look can — the op is only meaningful once appended, and
97 * holding it costs nothing.
98 */
99export async function hostingAttach ({ did, handle, pdsUrl, signer, plcUrl = null, submit = false } = {}) {
100 if (!did) throw new Error('hostingAttach: did required')
101 const plc = directory(plcUrl)
102 const prevSignedOp = await plc.getHead(did)
103 const op = attachHosting({ prevSignedOp, handle, pdsUrl, signerPrivateKey: signer.privateKey })
104 const preview = describeChange(prevSignedOp, op)
105 if (!submit) return { submitted: false, op, preview }
106 const data = await plc.submitUpdate({ did, signedOp: op })
107 return { submitted: true, op, preview, data }
108}
109
110/** Leave hosting, returning the identity to P2P-only. Same preview-by-default rule. */
111export async function hostingDetach ({ did, signer, plcUrl = null, submit = false } = {}) {
112 if (!did) throw new Error('hostingDetach: did required')
113 const plc = directory(plcUrl)
114 const prevSignedOp = await plc.getHead(did)
115 const op = detachHosting({ prevSignedOp, signerPrivateKey: signer.privateKey })
116 const preview = describeChange(prevSignedOp, op)
117 if (!submit) return { submitted: false, op, preview }
118 const data = await plc.submitUpdate({ did, signedOp: op })
119 return { submitted: true, op, preview, data }
120}
121
122/** Render a preview the way the CLI prints it. Exported so the shape is testable. */
123export function formatPreview ({ did, preview, submitted }) {
124 const lines = []
125 lines.push((submitted ? 'APPENDED to the PLC log for ' : 'WOULD append to the PLC log for ') + did)
126 for (const c of preview.changes) lines.push(' ' + c.field + ': ' + c.from + ' -> ' + c.to)
127 if (!preview.changes.length) lines.push(' (no changes)')
128 lines.push(' rotation keys: ' + (preview.kept.rotationKeys ? 'unchanged' : '⛔ CHANGED'))
129 lines.push(' signing key: ' + (preview.kept.verificationMethods ? 'unchanged' : '⛔ CHANGED'))
130 if (!submitted) lines.push('\nThis is PERMANENT and PUBLIC once submitted. Re-run with --yes to append it.')
131 return lines.join('\n')
132}
133
134/**
135 * The `hosting-*` subcommands.
136 *
137 * ⛔ PREVIEW IS THE DEFAULT AND --yes IS THE ONLY WAY PAST IT. A PLC append is permanent, public and
138 * unerasable, so the ceremony shows the exact change and stops. That is also the shape a web UI wants:
139 * render the preview, then a confirm button posts the same call with submit:true.
140 */
141export async function hostingCommand (argv) {
142 const sub = argv[0]
143 const flag = (n) => { const i = argv.indexOf('--' + n); return i < 0 ? null : argv[i + 1] }
144 const has = (n) => argv.includes('--' + n)
145 const did = argv.find((a) => a.startsWith('did:'))
146 const plcUrl = flag('plc')
147
148 if (!did) {
149 console.error('usage:\n' +
150 ' hyper-proto-peer hosting-show <did> [--plc <url>]\n' +
151 ' hyper-proto-peer hosting-attach <did> --handle <h> --pds <url> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n' +
152 ' hyper-proto-peer hosting-detach <did> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n\n' +
153 'The secret is read from a FILE, never from the command line — argv is visible in ps.')
154 return 1
155 }
156
157 if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
158 const fs = await import('bare-fs')
159
160 if (sub === 'hosting-show') {
161 const s = await hostingShow({ did, plcUrl })
162 console.log(JSON.stringify({ did: s.did, handle: s.handle, pds: s.pds, rotationKeys: s.rotationKeys }, null, 2))
163 return 0
164 }
165
166 const read = (p) => fs.default.readFileSync(p, 'utf8').trim()
167 let signer
168 try {
169 signer = resolveSigner({
170 mnemonic: flag('mnemonic-file') ? read(flag('mnemonic-file')) : null,
171 vault: flag('vault') ? JSON.parse(read(flag('vault'))) : null,
172 passphrase: flag('passphrase-file') ? read(flag('passphrase-file')) : null,
173 did
174 })
175 } catch (e) { console.error(e.message); return 1 }
176
177 const submit = has('yes')
178 const r = sub === 'hosting-detach'
179 ? await hostingDetach({ did, signer, plcUrl, submit })
180 : await hostingAttach({ did, handle: flag('handle'), pdsUrl: flag('pds'), signer, plcUrl, submit })
181
182 console.log(formatPreview({ did, preview: r.preview, submitted: r.submitted }))
183 if (r.submitted) {
184 console.log('\nNext, on the machine running the bridge: add this DID to `frontedDids` in its config.')
185 console.log('That half is the OPERATOR\'s and cannot be done from here — which is the point.')
186 }
187 return 0
188}
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.121",
4 "description": "Self-custody, P2P-native atproto peer — holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
3 "version": "0.3.122",
4 "description": "Self-custody, P2P-native atproto peer \u2014 holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
77 "bin": {
test/all.js +1 -0
……@@ -10,6 +10,7 @@ import './side-store-channel-priority.test.js' // fast + offline
1010import './shared-store-autoopen.test.js' // fast + offline; corestore auto-opens on-disk cores on remote interest (#22)
1111import './inline-migration-boot.test.mjs' // fast + offline
1212import './mint.test.mjs' // fast + offline; creating an identity on the device that will use it
13import './hosting-cli.test.mjs' // fast + offline; the invite ceremony's terminal half
1314import './swarm-device.test.js'
1415import './event-driven-sign.test.mjs'
1516import './failover-election.test.mjs'
test/hosting-cli.test.mjs +95 -0
……@@ -0,0 +1,95 @@
1// The invite ceremony's terminal half — the parts a web UI will reuse verbatim.
2//
3// The network half (getHead / submitUpdate) is PlcDirectory's and is not re-tested here. What is tested
4// is everything a person or a UI actually decides on: what the preview says, and how the recovery key is
5// obtained. Both are pure, which is why they can be shared with a bridge web UI later instead of being
6// reimplemented in it — the preview someone approves must be computed once, not twice.
7//
8// Run: bare test/hosting-cli.test.mjs
9import '../lib/bare-globals.js'
10import test from 'brittle'
11import { createSwarmIdentity, deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
12import { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js'
13import { sealVault } from 'atproto-identity/lib/vault.js'
14import { describeChange, resolveSigner, formatPreview } from '../lib/hosting-cli.js'
15
16const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
17const HANDLE = 'flaky.7rnx.net'
18const PDS = 'https://pds.7rnx.net'
19const recovery = () => deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH)
20
21test('the preview names exactly what changes', (t) => {
22 const id = createSwarmIdentity(MNEMONIC) // P2P-only
23 const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
24 const p = describeChange(id.signedOp, op)
25
26 const byField = Object.fromEntries(p.changes.map((c) => [c.field, c]))
27 t.is(byField.handle.from, '(none)', 'was handleless')
28 t.is(byField.handle.to, 'at://' + HANDLE, 'claims the handle')
29 t.is(byField.pds.from, '(none)', 'named no PDS')
30 t.is(byField.pds.to, PDS, 'names one now')
31})
32
33test('⛔ the preview ASSERTS that keys are carried forward', (t) => {
34 // A PLC op replaces the whole document, so "unchanged" is a property of the op, not a law. Silently
35 // dropping a rotation key is the one mistake nobody recovers from, so the preview states it.
36 const id = createSwarmIdentity(MNEMONIC)
37 const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
38 const p = describeChange(id.signedOp, op)
39 t.ok(p.kept.rotationKeys, 'rotation keys reported unchanged')
40 t.ok(p.kept.verificationMethods, 'signing key reported unchanged')
41
42 // And it must NOTICE when they do change, or the assertion is decoration.
43 const tampered = { ...op, rotationKeys: [] }
44 t.absent(describeChange(id.signedOp, tampered).kept.rotationKeys, 'a dropped rotation key is reported as CHANGED')
45})
46
47test('detaching previews as a release, not a claim', (t) => {
48 const id = createSwarmIdentity(MNEMONIC)
49 const joined = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
50 const left = detachHosting({ prevSignedOp: joined, signerPrivateKey: recovery().privateKey })
51 const byField = Object.fromEntries(describeChange(joined, left).changes.map((c) => [c.field, c]))
52 t.is(byField.handle.to, '(none)', 'the handle goes back to none')
53 t.is(byField.pds.to, '(none)', 'and so does the PDS')
54})
55
56test('the recovery key comes from a mnemonic file', (t) => {
57 const s = resolveSigner({ mnemonic: MNEMONIC })
58 t.is(s.didKey, recovery().didKey, 'derives the RECOVERY key, not the signing key')
59})
60
61test('...or from the sealed vault the phone already writes', (t) => {
62 // The path that matters: mint seals the mnemonic on the handset, so the secret reaches another device
63 // without ever being typed.
64 const id = createSwarmIdentity(MNEMONIC)
65 const envelope = sealVault({ secret: MNEMONIC, passphrase: 'correct horse battery staple', did: id.did })
66 const s = resolveSigner({ vault: envelope, passphrase: 'correct horse battery staple', did: id.did })
67 t.is(s.didKey, recovery().didKey, 'the vault yields the same recovery key')
68})
69
70test('⛔ a vault will not open without its passphrase, or for another identity', (t) => {
71 const id = createSwarmIdentity(MNEMONIC)
72 const envelope = sealVault({ secret: MNEMONIC, passphrase: 'right', did: id.did })
73 t.exception(() => resolveSigner({ vault: envelope, passphrase: 'wrong', did: id.did }), 'a wrong passphrase fails')
74 t.exception(() => resolveSigner({ vault: envelope, passphrase: 'right', did: 'did:plc:someoneelse' }), 'and so does the wrong identity')
75 t.exception(() => resolveSigner({ vault: envelope, did: id.did }), /passphrase/, 'and a missing passphrase is named, not guessed at')
76})
77
78test('⛔ with no source at all it refuses rather than inventing a key', (t) => {
79 t.exception(() => resolveSigner({}), /mnemonic-file|vault/, 'and tells you the two ways to supply one')
80})
81
82test('the printed preview says PERMANENT before it has happened, and not after', (t) => {
83 const id = createSwarmIdentity(MNEMONIC)
84 const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
85 const preview = describeChange(id.signedOp, op)
86
87 const dry = formatPreview({ did: id.did, preview, submitted: false })
88 t.ok(/WOULD append/.test(dry), 'a dry run says WOULD')
89 t.ok(/PERMANENT and PUBLIC/.test(dry), 'and warns before, which is the only time a warning helps')
90 t.ok(/--yes/.test(dry), 'and says how to proceed')
91
92 const done = formatPreview({ did: id.did, preview, submitted: true })
93 t.ok(/^APPENDED/.test(done), 'a real run reports what it did')
94 t.absent(/--yes/.test(done), 'and does not tell you to do it again')
95})