| … | … | @@ -22,7 +22,7 @@ function acctFor (config, username) { |
| 22 | 22 | return host ? username + '@' + host : null |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | export async function admitClaim ({ registrations, admission, config, onRegistered }, params = {}) { | |
| 25 | export async function admitClaim ({ registrations, admission, config, onRegistered, observerFor = null, bridgeKeyPair = null }, params = {}) { | |
| 26 | 26 | if (!registrations) return { status: 404, body: { error: 'not found' } } |
| 27 | 27 | if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } } |
| 28 | 28 | |
| … | … | @@ -51,6 +51,24 @@ export async function admitClaim ({ registrations, admission, config, onRegister |
| 51 | 51 | return { status, body: { error: r.error } } |
| 52 | 52 | } |
| 53 | 53 | |
| 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 | ||
| 54 | 72 | // ⛔ HOLD THE FEED NOW, so a registration is a served actor rather than a row in a file. The caller |
| 55 | 73 | // supplies this: neither this file nor the router knows about swarms or corestores. |
| 56 | 74 | if (onRegistered) { |
| … | … | @@ -65,11 +83,13 @@ export async function admitClaim ({ registrations, admission, config, onRegister |
| 65 | 83 | // live recorded nothing on the device, while the bridge went on holding the name. The device |
| 66 | 84 | // then showed "none yet" for ever and could neither re-claim nor release. `pending` already |
| 67 | 85 | // 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 } : {}) } } | |
| 69 | 88 | } |
| 70 | 89 | } |
| 71 | 90 | |
| 72 | 91 | const host = config && config.host |
| 92 | const observer = await observerGrant() | |
| 73 | 93 | return { |
| 74 | 94 | status: 200, |
| 75 | 95 | body: { |
| … | … | @@ -77,7 +97,8 @@ export async function admitClaim ({ registrations, admission, config, onRegister |
| 77 | 97 | generation: r.generation, |
| 78 | 98 | acct: acctFor(config, r.username), |
| 79 | 99 | 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 } : {}) | |
| 81 | 102 | } |
| 82 | 103 | } |
| 83 | 104 | } |
| … | … | @@ -516,7 +516,12 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul |
| 516 | 516 | if (registrations && holder && holder.swarm) { |
| 517 | 517 | try { |
| 518 | 518 | 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 }) | |
| 520 | 525 | await reg.ready() |
| 521 | 526 | open.reg = reg |
| 522 | 527 | // ⛔ PRINTED, because it is what a peer needs in order to find this bridge at all. A key that |
| … | … | @@ -84,7 +84,8 @@ export function registerHandlers (deps) { |
| 84 | 84 | */ |
| 85 | 85 | export function serveRegistration ({ swarm, log = () => {}, ...deps }) { |
| 86 | 86 | 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 }) | |
| 88 | 89 | |
| 89 | 90 | const onconnection = (conn) => { |
| 90 | 91 | let rpc |
| … | … | @@ -58,7 +58,15 @@ export class Users { |
| 58 | 58 | throw new Error('Users: invalid username ' + JSON.stringify(entry && entry.username)) |
| 59 | 59 | } |
| 60 | 60 | 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 | } | |
| 62 | 70 | const user = { ...entry, username: name } |
| 63 | 71 | this._byName.set(name, user) |
| 64 | 72 | return user |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.18", | |
| 3 | "version": "0.10.19", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "ap-bridge", |
| 9 | "version": "0.10.18", | |
| 9 | "version": "0.10.19", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.18", | |
| 3 | "version": "0.10.19", | |
| 4 | 4 | "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -113,3 +113,48 @@ test('★ releasing over the same door needs the same proof', async (t) => { |
| 113 | 113 | proof = signAttested(d.admission.releaseBytes({ nonce, username: 'alice' }), me.cred) |
| 114 | 114 | t.is((await h.release({ username: 'alice', root: me.rootHex, nonce, proof })).status, 200, 'the holder can') |
| 115 | 115 | }) |
| 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. | |
| 122 | test('★★ 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 | ||
| 151 | test('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 | }) |
| … | … | @@ -33,6 +33,19 @@ test('⛔ a duplicate is refused, not silently overwritten', async (t) => { |
| 33 | 33 | t.exception(() => users.add({ username: 'ashatron' }), /duplicate/i) |
| 34 | 34 | }) |
| 35 | 35 | |
| 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. | |
| 39 | test('★ 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 | ||
| 36 | 49 | test('⛔ a bad username is refused at add time, as at construction', async (t) => { |
| 37 | 50 | const users = new Users([{ username: 'primary' }], 'ap.7rnx.net') |
| 38 | 51 | t.exception(() => users.add({ username: 'not a name' })) |