| … | … | @@ -98,7 +98,11 @@ const peers = new DidPeerRouter(frontStore, { |
| 98 | 98 | const requestedFile = path.join(cfg.storage, 'fronted-requested.json') |
| 99 | 99 | const readRequested = () => { try { return JSON.parse(fs.readFileSync(requestedFile, 'utf8')) } catch { return {} } } |
| 100 | 100 | const requested = readRequested() |
| 101 | for (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. | |
| 103 | for (const [id, r] of Object.entries(requested)) { | |
| 104 | try { peers.add(id, r && r.handle ? { handle: r.handle } : {}) } catch {} | |
| 105 | } | |
| 102 | 106 | if (Object.keys(requested).length) console.log(JSON.stringify({ msg: 'refronted on boot', count: Object.keys(requested).length })) |
| 103 | 107 | |
| 104 | 108 | try { |
| … | … | @@ -108,9 +112,16 @@ try { |
| 108 | 112 | peers, |
| 109 | 113 | admission: new Admission(), |
| 110 | 114 | 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 }) => { | |
| 112 | 123 | const all = readRequested() |
| 113 | all[id] = { root, at: Date.now() } | |
| 124 | all[id] = { root, handle: handle || null, at: Date.now() } | |
| 114 | 125 | fs.mkdirSync(cfg.storage, { recursive: true }) |
| 115 | 126 | fs.writeFileSync(requestedFile, JSON.stringify(all, null, 2)) |
| 116 | 127 | }, |
| … | … | @@ -32,9 +32,14 @@ export { Admission } |
| 32 | 32 | * The verbs, as plain async functions — testable without a DHT. The transport below is thin, and the |
| 33 | 33 | * rules are not in it. |
| 34 | 34 | */ |
| 35 | export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', log = () => {} }) { | |
| 35 | const HANDLE_NAME = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/ | |
| 36 | ||
| 37 | export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', userDomain = '.7rnx.net', pdsUrl = null, log = () => {} }) { | |
| 36 | 38 | 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 }), | |
| 38 | 43 | |
| 39 | 44 | challenge: async () => { |
| 40 | 45 | 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 |
| 53 | 58 | const ok = admission.verifyFront({ nonce: params.nonce, id: params.id, root: params.root, proof: params.proof }) |
| 54 | 59 | if (!ok.ok) return { status: 403, body: { error: ok.error } } |
| 55 | 60 | |
| 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 | ||
| 56 | 74 | const already = peers.isFronted ? peers.isFronted(params.id) : false |
| 57 | 75 | try { |
| 58 | 76 | // ⛔ No verifyKey supplied — the lazy allow-set add. The key resolves from PLC when something |
| 59 | 77 | // 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 } : {}) | |
| 61 | 83 | } catch (err) { |
| 62 | 84 | return { status: 500, body: { error: (err && err.message) || String(err) } } |
| 63 | 85 | } |
| … | … | @@ -67,16 +89,19 @@ export function frontHandlers ({ peers, admission, onFronted = null, policy = 'o |
| 67 | 89 | // the same way the handle map does. |
| 68 | 90 | if (onFronted) { |
| 69 | 91 | try { |
| 70 | await onFronted({ id: params.id, root: ok.root }) | |
| 92 | await onFronted({ id: params.id, root: ok.root, handle }) | |
| 71 | 93 | } catch (err) { |
| 72 | 94 | // ⚠ It IS fronted at this point. Reporting failure would send a peer away from something |
| 73 | 95 | // 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) } } | |
| 75 | 97 | } |
| 76 | 98 | } |
| 77 | 99 | |
| 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 } } | |
| 80 | 105 | } |
| 81 | 106 | } |
| 82 | 107 | } |
| … | … | @@ -28,7 +28,7 @@ async function identity () { |
| 28 | 28 | |
| 29 | 29 | function fakePeers () { |
| 30 | 30 | 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 } | |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | const deps = (extra = {}) => ({ peers: fakePeers(), admission: new Admission(), ...extra }) |
| … | … | @@ -124,3 +124,50 @@ test('⛔ a bridge that does not take requests says so', async (t) => { |
| 124 | 124 | t.is((await h.front({ id: ID, root: me.rootHex, nonce, proof })).status, 403) |
| 125 | 125 | t.alike(d.peers.added, []) |
| 126 | 126 | }) |
| 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. | |
| 131 | test('★★ 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 | ||
| 151 | test('⛔ 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 | ||
| 165 | test('⛔ 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 | }) |