| … | … | @@ -443,6 +443,27 @@ async function main () { |
| 443 | 443 | log({ msg: 'released', username }) |
| 444 | 444 | } |
| 445 | 445 | |
| 446 | // ⛔ THE SWARM DOOR onto the same registration flow. A peer has no outbound HTTP — favela-peer | |
| 447 | // has none at all — and both sides are already swarm members, so this is how a handset asks for a | |
| 448 | // name without anybody shelling into anything. The rules are `admit.js`; this is transport. | |
| 449 | // | |
| 450 | // ⚠ On the FeedHolder's swarm, not a second one: it already runs one to hold registered peers' | |
| 451 | // feeds, and two swarms in a process is two NAT mappings and two sets of connections for no gain. | |
| 452 | if (registrations && holder && holder.swarm) { | |
| 453 | try { | |
| 454 | const { serveRegistration } = await import('./lib/register-rpc.js') | |
| 455 | const reg = serveRegistration({ swarm: holder.swarm, registrations, admission, config, onRegistered: ctx.onRegistered, onReleased: ctx.onReleased, log }) | |
| 456 | await reg.ready() | |
| 457 | // ⛔ PRINTED, because it is what a peer needs in order to find this bridge at all. A key that | |
| 458 | // exists and is written nowhere is the favela-android device-key mistake one layer over. | |
| 459 | log({ msg: 'registration open over the swarm', bridgeKey: reg.key }) | |
| 460 | } catch (err) { | |
| 461 | // Non-fatal: the HTTP door still works, and a bridge that federates is worth more than one | |
| 462 | // that refuses to boot because a second door would not open. | |
| 463 | log({ msg: 'swarm registration unavailable', error: (err && err.message) || String(err) }) | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 446 | 467 | // ⛔ AND ON BOOT, or a restart silently unserves everyone who registered. The registry is |
| 447 | 468 | // durable; what was held is not, so it has to be re-held from what the registry remembers. |
| 448 | 469 | if (registrations) { |
| … | … | @@ -0,0 +1,88 @@ |
| 1 | // admit.js — claiming and releasing a name, once, for every transport that offers it. | |
| 2 | // | |
| 3 | // ⛔ ONE IMPLEMENTATION, TWO ADAPTERS. This lived inside the HTTP route, which was fine while HTTP | |
| 4 | // was the only way in. It is not: favela-peer has NO outbound HTTP at all (measured — no bare-fetch, | |
| 5 | // no bare-https, nothing), so a peer asking a bridge for a name has to do it over the swarm, which | |
| 6 | // both sides already speak. Copying the flow into a second transport would be the "written three | |
| 7 | // times and the third one written wrong" failure `favela-markdown/lib/reach.js` names. | |
| 8 | // | |
| 9 | // So the rules live here — proof first, then policy, then hold the feed — and `router.js` and | |
| 10 | // `register-rpc.js` are two doors onto the same room. | |
| 11 | // | |
| 12 | // ⚠ Returns `{ status, body }` rather than throwing. The status codes are HTTP's because they are | |
| 13 | // already the contract a peer reads (`taken` → pick another, `invite required` → go ask the | |
| 14 | // operator, 403 → you did not prove control) and inventing a second vocabulary for the swarm would | |
| 15 | // mean two things to keep in step. | |
| 16 | ||
| 17 | export async function admitClaim ({ registrations, admission, config, onRegistered }, params = {}) { | |
| 18 | if (!registrations) return { status: 404, body: { error: 'not found' } } | |
| 19 | if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } } | |
| 20 | ||
| 21 | // ⛔ CONTROL, BEFORE CONSENT — and before the request's shape is even examined. hyper-social #65 | |
| 22 | // §8: admission needs both and neither substitutes for the other. An identity root is PUBLIC, so | |
| 23 | // presenting one proves nothing; this is the half that was only ever asserted in a comment. | |
| 24 | const ok = admission.verifyClaim({ | |
| 25 | nonce: params.nonce, | |
| 26 | username: params.username, | |
| 27 | feedKey: params.feedKey || null, | |
| 28 | root: params.root, | |
| 29 | proof: params.proof | |
| 30 | }) | |
| 31 | if (!ok.ok) return { status: 403, body: { error: ok.error } } | |
| 32 | ||
| 33 | const r = await registrations.claim({ | |
| 34 | username: params.username, | |
| 35 | root: params.root, | |
| 36 | feedKey: params.feedKey || null, | |
| 37 | invite: params.invite || null | |
| 38 | }) | |
| 39 | if (!r.ok) { | |
| 40 | // ⛔ Distinguished, because a peer acts differently on each: `taken` means pick another name, | |
| 41 | // `invite required` means go and ask the operator, and a bad request is the peer's own bug. | |
| 42 | const status = r.error === 'taken' ? 409 : r.error === 'invite required' ? 403 : 400 | |
| 43 | return { status, body: { error: r.error } } | |
| 44 | } | |
| 45 | ||
| 46 | // ⛔ HOLD THE FEED NOW, so a registration is a served actor rather than a row in a file. The caller | |
| 47 | // supplies this: neither this file nor the router knows about swarms or corestores. | |
| 48 | if (onRegistered) { | |
| 49 | try { | |
| 50 | await onRegistered({ username: r.username, root: params.root, feedKey: params.feedKey || null }) | |
| 51 | } catch (err) { | |
| 52 | // ⚠ The name IS claimed at this point. Reporting success would promise an actor that does not | |
| 53 | // serve; releasing it would lose a name the peer was just told it had. So: claimed, and honest | |
| 54 | // that it is not live yet. | |
| 55 | return { status: 202, body: { username: r.username, generation: r.generation, pending: String(err.message || err) } } | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | const host = config && config.host | |
| 60 | return { | |
| 61 | status: 200, | |
| 62 | body: { | |
| 63 | username: r.username, | |
| 64 | generation: r.generation, | |
| 65 | acct: host ? r.username + '@' + host : null, | |
| 66 | actor: `https://${host}/ap/users/${r.username}/actor`, | |
| 67 | inbox: `https://${host}/ap/users/${r.username}/inbox` | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | export async function admitRelease ({ registrations, admission, onReleased }, params = {}) { | |
| 73 | if (!registrations) return { status: 404, body: { error: 'not found' } } | |
| 74 | if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } } | |
| 75 | ||
| 76 | // ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a | |
| 77 | // reused name a different actor, so there is no way back — and this used to compare a bare `root`, | |
| 78 | // which is public. Anyone who could read a root could destroy the actor behind it. | |
| 79 | const ok = admission.verifyRelease({ nonce: params.nonce, username: params.username, root: params.root, proof: params.proof }) | |
| 80 | if (!ok.ok) return { status: 403, body: { error: ok.error } } | |
| 81 | ||
| 82 | const rel = await registrations.release(params.username, { root: params.root }) | |
| 83 | if (!rel.ok) return { status: rel.error === 'not registered' ? 404 : 403, body: { error: rel.error } } | |
| 84 | if (onReleased) { try { await onReleased(params.username) } catch {} } | |
| 85 | return { status: 200, body: { released: rel.username } } | |
| 86 | } | |
| 87 | ||
| 88 | export default { admitClaim, admitRelease } |
| … | … | @@ -0,0 +1,101 @@ |
| 1 | // register-rpc.js — asking this bridge for a name over the SWARM. | |
| 2 | // | |
| 3 | // ⛔ WHY NOT HTTP. favela-peer has no outbound HTTP at all — measured, not assumed: no bare-fetch, no | |
| 4 | // bare-https, nothing in `lib/`. Giving it one to register a name would add a client, and with it the | |
| 5 | // bare-https defect where an idle socket is never reaped (24.3 GB and two OOMs). Both sides already | |
| 6 | // speak the swarm, and this bridge is already a member of it — `feed-holder.js` runs one to hold | |
| 7 | // registered peers' feeds, and its own header says a bridge "is a service peers CONNECT TO". | |
| 8 | // | |
| 9 | // ⛔ THE SAME ROOM, A SECOND DOOR. Every rule lives in `admit.js`; this file is transport. A copy of | |
| 10 | // the claim flow here would be the failure `favela-markdown/lib/reach.js` names — written twice, and | |
| 11 | // the second one eventually written wrong. | |
| 12 | // | |
| 13 | // ⛔ AUTHORISATION IS THE CHAIN PROOF, NOT THE CONNECTION. hyper-proto-peer gates `apply-write` on | |
| 14 | // `conn.remotePublicKey` against an allowlist because it relays for known callers. A bridge accepts | |
| 15 | // requests from ANYONE — that is what an open bridge is — so what must be proved is WHICH IDENTITY, | |
| 16 | // not which socket. The swarm key says which connection; `readAttested` says who. Do not substitute | |
| 17 | // one for the other. | |
| 18 | ||
| 19 | import ProtomuxRPC from 'protomux-rpc' | |
| 20 | import c from 'compact-encoding' | |
| 21 | import b4a from 'b4a' | |
| 22 | ||
| 23 | export const PROTOCOL = 'favela/bridge-register/1' | |
| 24 | ||
| 25 | /** | |
| 26 | * The verbs, as plain async functions. Exported separately from the transport so they can be tested | |
| 27 | * without a DHT — the wiring below is thin enough to read, and the rules are not in it. | |
| 28 | */ | |
| 29 | export function registerHandlers (deps) { | |
| 30 | return { | |
| 31 | /** What a peer asks BEFORE trying, so a refusal is never a surprise. */ | |
| 32 | policy: async () => ({ | |
| 33 | policy: deps.registrations ? deps.registrations.policy : null, | |
| 34 | host: (deps.config && deps.config.host) || null, | |
| 35 | proof: !!deps.admission | |
| 36 | }), | |
| 37 | ||
| 38 | /** ⛔ Single-use, and required: readAttested has no clock, so replay is caught here or nowhere. */ | |
| 39 | challenge: async () => { | |
| 40 | if (!deps.admission) throw new Error('registration is not configured on this bridge') | |
| 41 | return deps.admission.challenge() | |
| 42 | }, | |
| 43 | ||
| 44 | claim: async (params = {}) => { | |
| 45 | const { admitClaim } = await import('./admit.js') | |
| 46 | return admitClaim(deps, params) | |
| 47 | }, | |
| 48 | ||
| 49 | release: async (params = {}) => { | |
| 50 | const { admitRelease } = await import('./admit.js') | |
| 51 | return admitRelease(deps, params) | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Serve those verbs to anyone who connects on this bridge's own key. | |
| 58 | * | |
| 59 | * ⛔ THE TOPIC IS THE BRIDGE'S SWARM KEY, joined `{ server: true, client: true }`. Both flags are | |
| 60 | * load-bearing and hyper-proto-peer measured why: a member that joins `client: false` never sends | |
| 61 | * outbound holepunch packets, so its NAT mapping never opens and a peer punching from the other side | |
| 62 | * is dropped. A NAT'd handset registering with a NAT'd bridge host is exactly that case — symmetric | |
| 63 | * connects in ~2s, asymmetric never open at all. | |
| 64 | * | |
| 65 | * ⚠ The key is what a peer needs in order to find this bridge, so it is RETURNED and should be | |
| 66 | * published somewhere a person can read it. | |
| 67 | */ | |
| 68 | export function serveRegistration ({ swarm, log = () => {}, ...deps }) { | |
| 69 | if (!swarm) throw new Error('serveRegistration: a swarm is required') | |
| 70 | const handlers = registerHandlers(deps) | |
| 71 | ||
| 72 | const onconnection = (conn) => { | |
| 73 | let rpc | |
| 74 | try { | |
| 75 | rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json }) | |
| 76 | } catch { return } | |
| 77 | for (const [name, fn] of Object.entries(handlers)) { | |
| 78 | rpc.respond(name, async (params) => { | |
| 79 | try { | |
| 80 | return await fn(params || {}) | |
| 81 | } catch (err) { | |
| 82 | // ⚠ Answered, never thrown across the wire as a dead channel: a peer that gets no reply | |
| 83 | // cannot tell a refusal from a bridge that is down, and those need different actions. | |
| 84 | return { status: 500, body: { error: (err && err.message) || String(err) } } | |
| 85 | } | |
| 86 | }) | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | swarm.on('connection', onconnection) | |
| 91 | const topic = swarm.keyPair.publicKey | |
| 92 | const disc = swarm.join(topic, { server: true, client: true }) | |
| 93 | ||
| 94 | return { | |
| 95 | key: b4a.toString(topic, 'hex'), | |
| 96 | ready: () => disc.flushed(), | |
| 97 | close: () => { try { swarm.off('connection', onconnection) } catch {} } | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | export default { serveRegistration, registerHandlers, PROTOCOL } |
| … | … | @@ -59,21 +59,11 @@ export async function handleRequest (req, ctx) { |
| 59 | 59 | if (method === 'DELETE') { |
| 60 | 60 | let del |
| 61 | 61 | try { del = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) } |
| 62 | // ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a | |
| 63 | // reused name a different actor, so there is no way back — and this used to compare a bare | |
| 64 | // `root`, which is public. Anyone who could read a root could destroy the actor behind it. | |
| 65 | // ⛔ NOT OPTIONAL. A conditional gate is a gate that is absent wherever the condition is, and | |
| 66 | // "the tests passed" would then mean "the tests ran without it". | |
| 67 | if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' }) | |
| 68 | { | |
| 69 | const ok = ctx.admission.verifyRelease({ nonce: del.nonce, username: del.username, root: del.root, proof: del.proof }) | |
| 70 | if (!ok.ok) return json(403, { error: ok.error }) | |
| 71 | } | |
| 72 | const rel = await ctx.registrations.release(del.username, { root: del.root }) | |
| 73 | if (!rel.ok) return json(rel.error === 'not registered' ? 404 : 403, { error: rel.error }) | |
| 74 | if (ctx.onReleased) { try { await ctx.onReleased(del.username) } catch {} } | |
| 75 | return json(200, { released: rel.username }) | |
| 62 | const { admitRelease } = await import('./admit.js') | |
| 63 | const r = await admitRelease(ctx, del) | |
| 64 | return json(r.status, r.body) | |
| 76 | 65 | } |
| 66 | ||
| 77 | 67 | /** |
| 78 | 68 | * ⛔ PUSH A PROFILE REFRESH. A remote server caches an actor and re-fetches on its own schedule |
| 79 | 69 | * — roughly daily — so a changed name, avatar or bio is invisible until then. An Update is the |
| … | … | @@ -93,58 +83,9 @@ export async function handleRequest (req, ctx) { |
| 93 | 83 | |
| 94 | 84 | let req_ |
| 95 | 85 | try { req_ = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) } |
| 96 | ||
| 97 | // ⛔ CONTROL, before consent. hyper-social #65 §8: admission needs both and neither substitutes | |
| 98 | // for the other. The policy half (open/invite) was built; this half was only ever asserted in a | |
| 99 | // comment, so an identity root — which is PUBLIC — was accepted as proof of holding it. | |
| 100 | if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' }) | |
| 101 | { | |
| 102 | const ok = ctx.admission.verifyClaim({ | |
| 103 | nonce: req_.nonce, | |
| 104 | username: req_.username, | |
| 105 | feedKey: req_.feedKey || null, | |
| 106 | root: req_.root, | |
| 107 | proof: req_.proof | |
| 108 | }) | |
| 109 | if (!ok.ok) return json(403, { error: ok.error }) | |
| 110 | } | |
| 111 | ||
| 112 | const r = await ctx.registrations.claim({ | |
| 113 | username: req_.username, | |
| 114 | root: req_.root, | |
| 115 | feedKey: req_.feedKey || null, | |
| 116 | invite: req_.invite || null | |
| 117 | }) | |
| 118 | if (!r.ok) { | |
| 119 | // ⛔ Distinguished, because a peer acts differently on each: `taken` means pick another name, | |
| 120 | // `invite required` means go and ask the operator, and a bad request is the peer's own bug. | |
| 121 | const status = r.error === 'taken' ? 409 : r.error === 'invite required' ? 403 : 400 | |
| 122 | return json(status, { error: r.error }) | |
| 123 | } | |
| 124 | ||
| 125 | // ⚠ The feed key is recorded but NOT yet used: this bridge still reads a co-located peer over a | |
| 126 | // socket, so holding a remote feed is the next piece. Taking it now means a peer registers once | |
| 127 | // rather than twice. | |
| 128 | // ⛔ HOLD IT NOW, so a registration is a served actor rather than a row in a file. index.js | |
| 129 | // supplies this: the router knows nothing about swarms or corestores, and should not. | |
| 130 | if (ctx.onRegistered) { | |
| 131 | try { | |
| 132 | await ctx.onRegistered({ username: r.username, root: req_.root, feedKey: req_.feedKey || null }) | |
| 133 | } catch (err) { | |
| 134 | // ⚠ The name IS claimed at this point. Reporting success would promise an actor that does | |
| 135 | // not serve; releasing it would lose a name the peer was told it had. So: claimed, and | |
| 136 | // honest that it is not live yet. | |
| 137 | return json(202, { username: r.username, generation: r.generation, pending: String(err.message || err) }) | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | const host = ctx.config && ctx.config.host | |
| 142 | return json(200, { | |
| 143 | username: r.username, | |
| 144 | generation: r.generation, | |
| 145 | actor: `https://${host}/ap/users/${r.username}/actor`, | |
| 146 | inbox: `https://${host}/ap/users/${r.username}/inbox` | |
| 147 | }) | |
| 86 | const { admitClaim } = await import('./admit.js') | |
| 87 | const r = await admitClaim(ctx, req_) | |
| 88 | return json(r.status, r.body) | |
| 148 | 89 | } |
| 149 | 90 | |
| 150 | 91 | /** |
| … | … | @@ -0,0 +1,115 @@ |
| 1 | // The swarm door onto registration — the same room as the HTTP one. | |
| 2 | // | |
| 3 | // ⛔ WHY IT EXISTS. favela-peer has no outbound HTTP (measured: no bare-fetch, no bare-https, nothing | |
| 4 | // in lib/), so a peer asking a bridge for a name cannot use the HTTP route. Both already speak the | |
| 5 | // swarm. What must NOT happen is a second copy of the rules, so these assert that this door reaches | |
| 6 | // the same `admit.js` and refuses exactly what the other door refuses. | |
| 7 | ||
| 8 | import test from 'brittle' | |
| 9 | import b4a from 'b4a' | |
| 10 | import IdentityKey from 'keet-identity-key' | |
| 11 | import crypto from 'hypercore-crypto' | |
| 12 | import { signAttested } from 'favela-identity/lib/attestation.js' | |
| 13 | import { MemoryRegistrations } from '../lib/registrations.js' | |
| 14 | import { Admission } from '../lib/admission.js' | |
| 15 | import { registerHandlers } from '../lib/register-rpc.js' | |
| 16 | ||
| 17 | const FEED = 'f'.repeat(64) | |
| 18 | ||
| 19 | async function identity () { | |
| 20 | const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() }) | |
| 21 | const kp = crypto.keyPair() | |
| 22 | const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey) | |
| 23 | return { rootHex: b4a.toString(idk.identityKeyPair.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } } | |
| 24 | } | |
| 25 | ||
| 26 | const deps = (policy = 'open', extra = {}) => ({ | |
| 27 | registrations: new MemoryRegistrations({ policy }), | |
| 28 | admission: new Admission(), | |
| 29 | config: { host: 'ap.7rnx.net' }, | |
| 30 | ...extra | |
| 31 | }) | |
| 32 | ||
| 33 | test('★ a peer asks the policy before trying, rather than guessing from a refusal', async (t) => { | |
| 34 | const h = registerHandlers(deps('invite')) | |
| 35 | const p = await h.policy() | |
| 36 | t.is(p.policy, 'invite') | |
| 37 | t.is(p.host, 'ap.7rnx.net') | |
| 38 | t.ok(p.proof, 'and that this bridge requires a proof') | |
| 39 | }) | |
| 40 | ||
| 41 | test('★★ the whole ceremony over the swarm door: challenge, sign, claim', async (t) => { | |
| 42 | const d = deps('open') | |
| 43 | const h = registerHandlers(d) | |
| 44 | const me = await identity() | |
| 45 | ||
| 46 | const { nonce } = await h.challenge() | |
| 47 | const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 48 | const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof }) | |
| 49 | ||
| 50 | t.is(r.status, 200) | |
| 51 | t.is(r.body.acct, 'alice@ap.7rnx.net', 'the name a person actually types') | |
| 52 | t.is(r.body.actor, 'https://ap.7rnx.net/ap/users/alice/actor') | |
| 53 | t.is((await d.registrations.get('alice')).root, me.rootHex) | |
| 54 | }) | |
| 55 | ||
| 56 | test('⛔ it refuses exactly what the HTTP door refuses', async (t) => { | |
| 57 | const d = deps('open') | |
| 58 | const h = registerHandlers(d) | |
| 59 | const me = await identity() | |
| 60 | const other = await identity() | |
| 61 | ||
| 62 | t.is((await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED })).status, 403, 'no proof') | |
| 63 | ||
| 64 | const { nonce } = await h.challenge() | |
| 65 | const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), other.cred) | |
| 66 | const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof }) | |
| 67 | t.is(r.status, 403, 'a proof from somebody else') | |
| 68 | t.is(r.body.error, 'proof does not attest that identity') | |
| 69 | ||
| 70 | t.is(await d.registrations.get('alice'), null, 'and nothing was registered') | |
| 71 | }) | |
| 72 | ||
| 73 | test('⛔ the feed is held at claim time, so a name is a served actor not a row in a file', async (t) => { | |
| 74 | const held = [] | |
| 75 | const d = deps('open', { onRegistered: async (u) => { held.push(u) } }) | |
| 76 | const h = registerHandlers(d) | |
| 77 | const me = await identity() | |
| 78 | ||
| 79 | const { nonce } = await h.challenge() | |
| 80 | const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 81 | t.is((await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })).status, 200) | |
| 82 | t.is(held.length, 1) | |
| 83 | t.is(held[0].feedKey, FEED, 'the bridge was told which feed to hold') | |
| 84 | }) | |
| 85 | ||
| 86 | test('⚠ a name claimed but not servable says so, rather than promising an actor that is dead', async (t) => { | |
| 87 | const d = deps('open', { onRegistered: async () => { throw new Error('swarm unreachable') } }) | |
| 88 | const h = registerHandlers(d) | |
| 89 | const me = await identity() | |
| 90 | ||
| 91 | const { nonce } = await h.challenge() | |
| 92 | const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 93 | const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof }) | |
| 94 | ||
| 95 | t.is(r.status, 202, 'claimed, and honest that it is not live') | |
| 96 | t.ok(/swarm unreachable/.test(r.body.pending), r.body.pending) | |
| 97 | t.ok(await d.registrations.get('alice'), 'the name IS held — releasing it would lose what the peer was told it had') | |
| 98 | }) | |
| 99 | ||
| 100 | test('★ releasing over the same door needs the same proof', async (t) => { | |
| 101 | const d = deps('open') | |
| 102 | const h = registerHandlers(d) | |
| 103 | const me = await identity() | |
| 104 | ||
| 105 | let { nonce } = await h.challenge() | |
| 106 | let proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 107 | await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof }) | |
| 108 | ||
| 109 | t.is((await h.release({ username: 'alice', root: me.rootHex })).status, 403, 'unproven cannot burn it') | |
| 110 | t.ok(await d.registrations.get('alice'), 'still held') | |
| 111 | ||
| 112 | ;({ nonce } = await h.challenge()) | |
| 113 | proof = signAttested(d.admission.releaseBytes({ nonce, username: 'alice' }), me.cred) | |
| 114 | t.is((await h.release({ username: 'alice', root: me.rootHex, nonce, proof })).status, 200, 'the holder can') | |
| 115 | }) |