OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 4e096f0

0.10.19: a claim returns the signed observer grant; a re-claim is not a duplicate (I1 S3)
The claim reply carries {observationFeed, observerRoot, sig}, signed with the bridge swarm key (the bridgeKey a peer claimed against; favela-identity 0.0.5 observer-grant). Users.add is idempotent for the SAME registration (root + feed), so a re-claim answers 200 with the grant instead of 202 pending - how fleeky/ashatron, registered before observation feeds, learn theirs. Test-first; 387/387. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4e096f0ccffae490b9fa899af6413b2d055be76c
by plankatron <fleeky@gmail.com> · 2026-10-14T19:27:11.000Z

Changed files (8) +102 -9

lib/admit.js +24 -3
……@@ -22,7 +22,7 @@ function acctFor (config, username) {
2222 return host ? username + '@' + host : null
2323}
2424
25export async function admitClaim ({ registrations, admission, config, onRegistered }, params = {}) {
25export async function admitClaim ({ registrations, admission, config, onRegistered, observerFor = null, bridgeKeyPair = null }, params = {}) {
2626 if (!registrations) return { status: 404, body: { error: 'not found' } }
2727 if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } }
2828
……@@ -51,6 +51,24 @@ export async function admitClaim ({ registrations, admission, config, onRegister
5151 return { status, body: { error: r.error } }
5252 }
5353
54 // ⛔ THE USER'S OBSERVATION FEED, VOUCHED FOR BY THIS BRIDGE (hyper-social #82, I1 S3). What the fediverse does
55 // to this user is written into a feed the bridge founds for them; the peer holds it and pins its founder. The
56 // grant is signed with the bridge's swarm key — the key the peer claimed against (operator decision O-2 b) — and
57 // travels with EVERY successful claim, re-claims included, which is how an already-registered peer learns it.
58 // Optional: a bridge with no observer, or one whose feed could not open, answers exactly as before.
59 const observerGrant = async () => {
60 if (!observerFor || !bridgeKeyPair) return null
61 try {
62 const g = await observerFor({ username: r.username, generation: r.generation })
63 if (!g || !g.observationFeed || !g.observerRoot) return null
64 const { signObserver } = await import('favela-identity/lib/observer-grant.js')
65 const grant = { username: r.username, generation: r.generation, observationFeed: g.observationFeed, observerRoot: g.observerRoot }
66 return { observationFeed: g.observationFeed, observerRoot: g.observerRoot, sig: signObserver(grant, bridgeKeyPair) }
67 } catch {
68 return null
69 }
70 }
71
5472 // ⛔ HOLD THE FEED NOW, so a registration is a served actor rather than a row in a file. The caller
5573 // supplies this: neither this file nor the router knows about swarms or corestores.
5674 if (onRegistered) {
……@@ -65,11 +83,13 @@ export async function admitClaim ({ registrations, admission, config, onRegister
6583 // live recorded nothing on the device, while the bridge went on holding the name. The device
6684 // then showed "none yet" for ever and could neither re-claim nor release. `pending` already
6785 // says it is not servable — that is the honest part; withholding the name is not.
68 return { status: 202, body: { username: r.username, generation: r.generation, acct: acctFor(config, r.username), pending: String(err.message || err) } }
86 const observer = await observerGrant()
87 return { status: 202, body: { username: r.username, generation: r.generation, acct: acctFor(config, r.username), pending: String(err.message || err), ...(observer ? { observer } : {}) } }
6988 }
7089 }
7190
7291 const host = config && config.host
92 const observer = await observerGrant()
7393 return {
7494 status: 200,
7595 body: {
……@@ -77,7 +97,8 @@ export async function admitClaim ({ registrations, admission, config, onRegister
7797 generation: r.generation,
7898 acct: acctFor(config, r.username),
7999 actor: `https://${host}/ap/users/${r.username}/actor`,
80 inbox: `https://${host}/ap/users/${r.username}/inbox`
100 inbox: `https://${host}/ap/users/${r.username}/inbox`,
101 ...(observer ? { observer } : {})
81102 }
82103 }
83104}
lib/bridge.js +6 -1
……@@ -516,7 +516,12 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
516516 if (registrations && holder && holder.swarm) {
517517 try {
518518 const { serveRegistration } = await import('./register-rpc.js')
519 const reg = serveRegistration({ swarm: holder.swarm, registrations, admission, observations, config, onRegistered: ctx.onRegistered, onReleased: ctx.onReleased, log })
519 // observerFor: the user's observation feed + this bridge's observer root, for the signed grant a claim
520 // returns (hyper-social #82, I1 S3). Opening is idempotent; a failure just omits the grant.
521 const observerFor = obsFeeds
522 ? async ({ username, generation }) => ({ observationFeed: (await obsFeeds.open(username, generation)).key, observerRoot: obsFeeds.root })
523 : null
524 const reg = serveRegistration({ swarm: holder.swarm, registrations, admission, observations, config, onRegistered: ctx.onRegistered, onReleased: ctx.onReleased, observerFor, log })
520525 await reg.ready()
521526 open.reg = reg
522527 // ⛔ PRINTED, because it is what a peer needs in order to find this bridge at all. A key that
lib/register-rpc.js +2 -1
……@@ -84,7 +84,8 @@ export function registerHandlers (deps) {
8484 */
8585export function serveRegistration ({ swarm, log = () => {}, ...deps }) {
8686 if (!swarm) throw new Error('serveRegistration: a swarm is required')
87 const handlers = registerHandlers(deps)
87 // The swarm keypair signs observer grants (admit.js): its public key IS the bridgeKey a peer claimed against.
88 const handlers = registerHandlers({ bridgeKeyPair: swarm.keyPair || null, ...deps })
8889
8990 const onconnection = (conn) => {
9091 let rpc
lib/users.js +9 -1
……@@ -58,7 +58,15 @@ export class Users {
5858 throw new Error('Users: invalid username ' + JSON.stringify(entry && entry.username))
5959 }
6060 const name = String(entry.username).toLowerCase()
61 if (this._byName.has(name)) throw new Error('Users: duplicate username ' + name)
61 if (this._byName.has(name)) {
62 // ⛔ A RE-CLAIM IS NOT A DUPLICATE. The same holder claiming its own name again — the same non-empty root
63 // and the same feed — is how a peer fetches what a claim now returns (its observation feed, #82 I1 S3).
64 // Throwing here made admit.js answer "202 pending" to a user who was fully served. Anything else under a
65 // taken name is still refused.
66 const have = this._byName.get(name)
67 if (entry.root && have.root === entry.root && (have.feedKey || null) === (entry.feedKey || null)) return have
68 throw new Error('Users: duplicate username ' + name)
69 }
6270 const user = { ...entry, username: name }
6371 this._byName.set(name, user)
6472 return user
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.18",
3 "version": "0.10.19",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "ap-bridge",
9 "version": "0.10.18",
9 "version": "0.10.19",
1010 "license": "MIT",
1111 "dependencies": {
1212 "atproto-did-peer": "file:../atproto-did-peer",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.18",
3 "version": "0.10.19",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/register-rpc.test.js +45 -0
……@@ -113,3 +113,48 @@ test('★ releasing over the same door needs the same proof', async (t) => {
113113 proof = signAttested(d.admission.releaseBytes({ nonce, username: 'alice' }), me.cred)
114114 t.is((await h.release({ username: 'alice', root: me.rootHex, nonce, proof })).status, 200, 'the holder can')
115115})
116
117// ── the claim reply names the user's observation feed (hyper-social #82, I1 S3) ─────────────────────────────
118//
119// ⛔ SIGNED BY THE BRIDGE KEY (operator decision O-2 b). The peer pins the observer root it is handed only because
120// it arrives signed by the swarm key it claimed against. And a RE-CLAIM returns it too — that is how a peer that
121// registered before observation feeds existed (fleeky, ashatron) learns its feed.
122test('★★ the claim reply carries a signed observer grant; a re-claim returns it again', async (t) => {
123 const { verifyObserver } = await import('favela-identity/lib/observer-grant.js')
124 const bridge = crypto.keyPair()
125 const OBS = '0b'.repeat(32)
126 const ROOT = 'e4'.repeat(32)
127 const d = deps('open', {
128 onRegistered: async () => {},
129 observerFor: async ({ username, generation }) => ({ username, generation, observationFeed: OBS, observerRoot: ROOT }),
130 bridgeKeyPair: bridge
131 })
132 const h = registerHandlers(d)
133 const me = await identity()
134 const claim = async () => {
135 const { nonce } = await h.challenge()
136 const proof = signAttested(d.admission.claimBytes({ nonce, username: 'fleeky', feedKey: FEED }), me.cred)
137 return h.claim({ username: 'fleeky', root: me.rootHex, feedKey: FEED, nonce, proof })
138 }
139 const r = await claim()
140 t.is(r.status, 200)
141 const o = r.body.observer
142 t.ok(o, 'the reply carries an observer grant')
143 t.alike({ observationFeed: o.observationFeed, observerRoot: o.observerRoot }, { observationFeed: OBS, observerRoot: ROOT })
144 t.is(verifyObserver({ username: 'fleeky', generation: r.body.generation, observationFeed: o.observationFeed, observerRoot: o.observerRoot }, o.sig, b4a.toString(bridge.publicKey, 'hex')), true, 'verifiable against the bridge key')
145
146 const again = await claim()
147 t.is(again.status, 200, 'a re-claim by the same holder is not "pending"')
148 t.is(again.body.observer && again.body.observer.observationFeed, OBS, 'and returns the grant again')
149})
150
151test('no observer configured: the reply is unchanged (no grant field)', async (t) => {
152 const d = deps('open', { onRegistered: async () => {} })
153 const h = registerHandlers(d)
154 const me = await identity()
155 const { nonce } = await h.challenge()
156 const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
157 const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
158 t.is(r.status, 200)
159 t.absent(r.body.observer)
160})
test/wire-registration.test.js +13 -0
……@@ -33,6 +33,19 @@ test('⛔ a duplicate is refused, not silently overwritten', async (t) => {
3333 t.exception(() => users.add({ username: 'ashatron' }), /duplicate/i)
3434})
3535
36// ⛔ A RE-CLAIM IS NOT A DUPLICATE. The same holder claiming its own name again (same root, same feed) is how a
37// peer asks for what a bridge now returns at claim time (its observation feed, #82 I1 S3). It used to throw here,
38// and admit.js turned that into "202 pending" for a user who was fully served.
39test('★ re-adding the SAME registration (same root and feed) is idempotent; any other is still refused', async (t) => {
40 const users = new Users([{ username: 'primary' }], 'ap.7rnx.net')
41 const first = users.add({ username: 'fleeky', root: 'a'.repeat(64), feedKey: 'f'.repeat(64) })
42 const again = users.add({ username: 'fleeky', root: 'a'.repeat(64), feedKey: 'f'.repeat(64) })
43 t.is(again, first, 'the same user, not a second one')
44 t.is(users.size, 2)
45 t.exception(() => users.add({ username: 'fleeky', root: 'b'.repeat(64), feedKey: 'f'.repeat(64) }), /duplicate/i, 'another root')
46 t.exception(() => users.add({ username: 'fleeky', root: 'a'.repeat(64), feedKey: 'e'.repeat(64) }), /duplicate/i, 'another feed')
47})
48
3649test('⛔ a bad username is refused at add time, as at construction', async (t) => {
3750 const users = new Users([{ username: 'primary' }], 'ap.7rnx.net')
3851 t.exception(() => users.add({ username: 'not a name' }))