OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / c8b350f

the membrane GRANTS the handle, and says what to adopt
A peer cannot choose its own handle — the membrane owns the namespace under its domain and is the only thing that can answer <handle>/.well-known/atproto-did. So fronting now takes a name, grants <name><userDomain>, records it with add() so the well-known route answers, and hands back the handle AND the endpoint. Those two values are exactly what the peer must put on-chain next. hpp's mint names this step: the handle and service endpoint "arrive LATER, as a PLC update op, when an invite from atproto-bridge or ap-bridge is accepted". policy() offers them BEFORE a claim, because accepting is a permanent append to a global log and a peer should see the terms first. The endpoint derives from serverDid rather than a new config key — config.js already takes crawlHostname's default from that same host part, and a second source would drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c8b350ffe305f1c2b9daa2488df4df0955a2b4fd
by plankatron <fleeky@gmail.com> · 2026-09-11T19:10:26.000Z

Changed files (3) +94 -11

pds/bin/pds-serve.js +14 -3
……@@ -98,7 +98,11 @@ const peers = new DidPeerRouter(frontStore, {
9898const requestedFile = path.join(cfg.storage, 'fronted-requested.json')
9999const readRequested = () => { try { return JSON.parse(fs.readFileSync(requestedFile, 'utf8')) } catch { return {} } }
100100const requested = readRequested()
101for (const id of Object.keys(requested)) { try { peers.add(id) } catch {} }
101// ⛔ The handle goes back in too, or a restart forgets the name it granted and
102// `<handle>/.well-known/atproto-did` stops answering for someone already published on-chain.
103for (const [id, r] of Object.entries(requested)) {
104 try { peers.add(id, r && r.handle ? { handle: r.handle } : {}) } catch {}
105}
102106if (Object.keys(requested).length) console.log(JSON.stringify({ msg: 'refronted on boot', count: Object.keys(requested).length }))
103107
104108try {
……@@ -108,9 +112,16 @@ try {
108112 peers,
109113 admission: new Admission(),
110114 policy: process.env.PDS_FRONT_POLICY || 'open',
111 onFronted: ({ id, root }) => {
115 // What the membrane OFFERS: the domain it grants names under, and the endpoint a DID must point
116 // at. A peer needs both before it commits, because accepting is a permanent append to a global log.
117 userDomain: cfg.userDomain,
118 // ⛔ DERIVED FROM serverDid, not invented. `did:web:<host>` IS this membrane's public identity —
119 // config.js already takes crawlHostname's default from exactly that host part, so using any other
120 // source here would be a second answer to "where is this membrane" that could drift from the first.
121 pdsUrl: 'https://' + (/^did:web:/.test(cfg.serverDid) ? cfg.serverDid.slice('did:web:'.length) : 'pds.7rnx.net'),
122 onFronted: ({ id, root, handle }) => {
112123 const all = readRequested()
113 all[id] = { root, at: Date.now() }
124 all[id] = { root, handle: handle || null, at: Date.now() }
114125 fs.mkdirSync(cfg.storage, { recursive: true })
115126 fs.writeFileSync(requestedFile, JSON.stringify(all, null, 2))
116127 },
pds/lib/fronting.js +32 -7
……@@ -32,9 +32,14 @@ export { Admission }
3232 * The verbs, as plain async functions — testable without a DHT. The transport below is thin, and the
3333 * rules are not in it.
3434 */
35export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', log = () => {} }) {
35const HANDLE_NAME = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/
36
37export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', userDomain = '.7rnx.net', pdsUrl = null, log = () => {} }) {
3638 return {
37 policy: async () => ({ policy, proof: !!admission }),
39 // ⛔ THE OFFER, not just a yes/no. A peer needs to know the handle suffix and the endpoint BEFORE
40 // it commits, because accepting is a permanent append to a global log — it cannot be undone if
41 // the answer turns out to be something else.
42 policy: async () => ({ policy, proof: !!admission, userDomain, pdsUrl }),
3843
3944 challenge: async () => {
4045 if (!admission) throw new Error('fronting is not configured on this bridge')
……@@ -53,11 +58,28 @@ export function frontHandlers ({ peers, admission, onFronted = null, policy = 'o
5358 const ok = admission.verifyFront({ nonce: params.nonce, id: params.id, root: params.root, proof: params.proof })
5459 if (!ok.ok) return { status: 403, body: { error: ok.error } }
5560
61 // ⛔ THE HANDLE IS GRANTED HERE, because the membrane owns the namespace under its domain and
62 // is the only thing that can answer `<handle>/.well-known/atproto-did`. A peer that chose its
63 // own handle would be claiming a name on somebody else's domain.
64 const name = String(params.name || '').trim().toLowerCase()
65 if (name && !HANDLE_NAME.test(name)) return { status: 400, body: { error: 'that is not a usable handle' } }
66 const handle = name ? name + userDomain : null
67
68 // ⛔ Taken by somebody ELSE is a refusal; taken by this same identity is a retry.
69 if (handle && peers.resolveHandle) {
70 const holder = peers.resolveHandle(handle)
71 if (holder && holder !== params.id) return { status: 409, body: { error: 'taken' } }
72 }
73
5674 const already = peers.isFronted ? peers.isFronted(params.id) : false
5775 try {
5876 // ⛔ No verifyKey supplied — the lazy allow-set add. The key resolves from PLC when something
5977 // actually asks for the repo, which is the keyless property: this bridge never holds one.
60 peers.add(params.id)
78 //
79 // ⚠ The handle goes in with it: `add()` records it, and that is what makes the well-known
80 // route answer for this name. Granting a handle and not recording it would be a name that
81 // resolves nowhere.
82 peers.add(params.id, handle ? { handle } : {})
6183 } catch (err) {
6284 return { status: 500, body: { error: (err && err.message) || String(err) } }
6385 }
……@@ -67,16 +89,19 @@ export function frontHandlers ({ peers, admission, onFronted = null, policy = 'o
6789 // the same way the handle map does.
6890 if (onFronted) {
6991 try {
70 await onFronted({ id: params.id, root: ok.root })
92 await onFronted({ id: params.id, root: ok.root, handle })
7193 } catch (err) {
7294 // ⚠ It IS fronted at this point. Reporting failure would send a peer away from something
7395 // that is working; reporting plain success would promise it survives a restart.
74 return { status: 202, body: { id: params.id, fronted: true, durable: false, reason: String(err.message || err) } }
96 return { status: 202, body: { id: params.id, handle, pdsUrl, fronted: true, durable: false, reason: String(err.message || err) } }
7597 }
7698 }
7799
78 log({ msg: 'fronting', id: params.id, root: ok.root, already })
79 return { status: 200, body: { id: params.id, fronted: true, durable: true, already } }
100 log({ msg: 'fronting', id: params.id, handle, root: ok.root, already })
101 // ⛔ THE HANDLE AND THE ENDPOINT COME BACK, because the peer's next act is a PLC update putting
102 // exactly these two values on-chain. hpp's mint names this step: the handle and service
103 // endpoint "arrive LATER, as a PLC update op, when an invite from a bridge is accepted".
104 return { status: 200, body: { id: params.id, handle, pdsUrl, fronted: true, durable: true, already } }
80105 }
81106 }
82107}
pds/test/bare/fronting.test.js +48 -1
……@@ -28,7 +28,7 @@ async function identity () {
2828
2929function fakePeers () {
3030 const added = []
31 return { added, add: (id) => { added.push(id) }, isFronted: (id) => added.includes(id) }
31 return { added, add: (id) => { added.push(id) }, isFronted: (id) => added.includes(id), resolveHandle: () => null }
3232}
3333
3434const deps = (extra = {}) => ({ peers: fakePeers(), admission: new Admission(), ...extra })
……@@ -124,3 +124,50 @@ test('⛔ a bridge that does not take requests says so', async (t) => {
124124 t.is((await h.front({ id: ID, root: me.rootHex, nonce, proof })).status, 403)
125125 t.alike(d.peers.added, [])
126126})
127
128// ⛔ THE MEMBRANE GRANTS THE HANDLE, and hands back what the peer must put on-chain next. hpp's mint
129// names this step: the handle and service endpoint "arrive LATER, as a PLC update op, when an invite
130// from a bridge is accepted". Without these two values coming back, the peer has nothing to adopt.
131test('★★ fronting grants a handle and names the endpoint to adopt', async (t) => {
132 const saved = []
133 const d = deps({ userDomain: '.7rnx.net', pdsUrl: 'https://pds.7rnx.net', onFronted: async (x) => saved.push(x) })
134 const h = frontHandlers(d)
135 const me = await identity()
136
137 const offer = await h.policy()
138 t.is(offer.userDomain, '.7rnx.net', 'a peer can see the suffix BEFORE committing to a permanent append')
139 t.is(offer.pdsUrl, 'https://pds.7rnx.net')
140
141 const { nonce } = await h.challenge()
142 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
143 const r = await h.front({ id: ID, name: 'alice', root: me.rootHex, nonce, proof })
144
145 t.is(r.status, 200)
146 t.is(r.body.handle, 'alice.7rnx.net', 'granted under the membrane domain, not chosen by the peer')
147 t.is(r.body.pdsUrl, 'https://pds.7rnx.net', 'and the endpoint the DID must point at')
148 t.is(saved[0].handle, 'alice.7rnx.net', 'persisted, or a restart forgets the name it granted')
149})
150
151test('⛔ a handle held by somebody else is refused, not reassigned', async (t) => {
152 const d = deps({ userDomain: '.7rnx.net' })
153 d.peers.resolveHandle = (h) => (h === 'alice.7rnx.net' ? 'did:plc:somebodyelse00000000000' : null)
154 const h = frontHandlers(d)
155 const me = await identity()
156 const { nonce } = await h.challenge()
157 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
158
159 const r = await h.front({ id: ID, name: 'alice', root: me.rootHex, nonce, proof })
160 t.is(r.status, 409)
161 t.is(r.body.error, 'taken')
162 t.alike(d.peers.added, [], 'and nothing was fronted')
163})
164
165test('⛔ a garbage handle is refused before anything is recorded', async (t) => {
166 const d = deps({ userDomain: '.7rnx.net' })
167 const h = frontHandlers(d)
168 const me = await identity()
169 const { nonce } = await h.challenge()
170 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
171 t.is((await h.front({ id: ID, name: 'not a handle', root: me.rootHex, nonce, proof })).status, 400)
172 t.alike(d.peers.added, [])
173})