OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 39c5d18

0.0.60: a knock proves WHICH IDENTITY is knocking (closes the listing-consent spoof)
⛔ THE DEFECT (hyper-social #77 §1). applyKnock recorded __knocks__/<op.feed> for whatever feed the op named, and applyListFeed read that row as the named feed's CONSENT to be listed. Nothing tied the knocking device to that feed, so anyone holding a registry's log could knock "as" any feed and get it listed without its owner asking. The comment claimed the opposite. test/knock-identity.test.js reproduced it ("the old spoof") before the fix. THE FIX. Inside apply this log cannot read another feed, so "this device belongs to feed X" is unprovable here, but "this device chains to identity root R" is provable from the proof's bytes, exactly as add-device does it (readProof binds THIS writer core and returns the root). - knock = { feed, proof }; the proof is bindWriter(cred, <knocking writer core>). No valid proof, no knock. The row is keyed by the PROVEN ROOT ({ device, feed }), so a flood is one row per identity. - list-feed names the root; it needs that root's live knock naming THIS feed. The root goes in a reserved __listing_root__/<feed> row, NOT on the pointer, which must stay exactly { $oid, size } (RecordLog isPointer). - dismiss-knock is by root. - READER half: Peer.directory() marks each listing verified true/false/null by checking the listed feed's founder against the granted root when we hold it. A spoofer's listing gets through apply (it cannot read the victim's feed) and reads verified:false. Surfaces are unchanged: Peer maps a feed key to its knock's root, so the CLI and favela-surface still pass feed keys. Feed.knock(myFeedKey, bind) takes a BINDER, not a credential, so feed-base never imports the vault code. The node sets peer.bind from its credential, read live. ⚠ An unproven knock is now a no-op, including ones already in a log (views re-derive on reindex). Measured first: zero listings on any node, one knock (between two of our own feeds). Daemons on <=0.0.59 still accept unproven knocks until upgraded. Tests: 405/405 (959 asserts): 7 new identity-knock tests, 1 new reader-verification test, 8 older files moved to the new semantics. ap-bridge 364/364, favela-surface 239/239 against this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
39c5d18d30ecfc787d5c7eaeadc2be8339cc0260
by plankatron <fleeky@gmail.com> · 2026-10-13T00:46:37.000Z

Changed files (16) +381 -90

README.md +1 -1
……@@ -157,7 +157,7 @@ nothing on this side can send one yet. The bridge half is built; see hyper-socia
157157
158158## Status
159159
160Working. `npm test` — 397 tests, 943 asserts, passing (`brittle-bare test/*.test.js`, 50 files,
160Working. `npm test` — 405 tests, 959 asserts, passing (`brittle-bare test/*.test.js`, 50 files,
161161~43 s).
162162
163163Deployed as a [pgh](../pgh) app. Measured 2026-09-20: 0.0.57 installed and running on tabertron and
lib/feed-base.js +32 -10
……@@ -25,7 +25,7 @@
2525
2626import Autobee from 'autobee'
2727import b4a from 'b4a'
28import { applyOp, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING } from './feed.js'
28import { applyOp, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT } from './feed.js'
2929import { RecordLog } from './log.js'
3030
3131const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
……@@ -228,8 +228,18 @@ export class Feed {
228228 //
229229 // ⛔ `base.writable` GOES TRUE ON THE APPENDER after an optimistic append — it means "I have a
230230 // local writer core", never "my appends are admitted". Do not use it as an authorisation check.
231 async knock (myFeedKey) {
232 await this.base.append(enc({ t: 'knock', feed: hex(myFeedKey) }), { optimistic: true })
231 //
232 // ⛔ THE KNOCK PROVES WHO IS KNOCKING (feed.js applyKnock). `bind(writerKeyHex)` returns a proof
233 // binding OUR writer core on their log to our identity root — `bindWriter(cred, key)`, the same
234 // proof add-device carries. It is a function rather than a credential so that this file never
235 // imports the vault code (device.js): only the node holding the credential builds one, and a
236 // read-only holder that never knocks never loads it.
237 async knock (myFeedKey, bind) {
238 if (typeof bind !== 'function') {
239 throw new Error('Feed.knock: a binder is required — a knock must prove which identity is knocking ' +
240 '(run `favela link` so this device has a credential)')
241 }
242 await this.base.append(enc({ t: 'knock', feed: hex(myFeedKey), proof: bind(this.deviceKey) }), { optimistic: true })
233243 }
234244
235245 // Honour a knock by listing that feed. Bytes first, then the op — same order as `put`, and for the
……@@ -237,10 +247,13 @@ export class Feed {
237247 //
238248 // ⚠ apply refuses this unless an active knock exists for the feed, so listing something that never
239249 // asked is not merely discouraged, it does not land. Order matters: list, THEN dismiss.
240 async listFeed (feedKey, record) {
250 //
251 // `root` is the identity whose proven knock is being honoured; apply refuses the listing unless that
252 // knock is live and named THIS feed.
253 async listFeed (root, feedKey, record) {
241254 const feed = hex(feedKey)
242255 const { oid, size } = await this.store.put(LISTING, feed, record)
243 await this.base.append(enc({ t: 'list-feed', feed, $oid: oid, size }))
256 await this.base.append(enc({ t: 'list-feed', root: hex(root), feed, $oid: oid, size }))
244257 return { oid, size }
245258 }
246259
……@@ -249,20 +262,29 @@ export class Feed {
249262 try { await this.store.del(LISTING, hex(feedKey)) } catch {}
250263 }
251264
265 // Each listing with the identity root it was granted to. A reader must still check that the feed's
266 // founder IS that root before trusting it (Peer.directory): apply cannot read the listed feed.
252267 async listings () {
253 return (await this.list(LISTING)).map((r) => ({ feed: r.name, record: r.record }))
268 const rows = await this.list(LISTING)
269 const out = []
270 for (const r of rows) {
271 const granted = await this._readKv().get(LISTING_ROOT + '/' + r.name)
272 out.push({ feed: r.name, record: r.record, root: granted && granted.$deleted !== true ? granted.root : null })
273 }
274 return out
254275 }
255276
256 async dismissKnock (feedKey) {
257 await this.base.append(enc({ t: 'dismiss-knock', feed: hex(feedKey) }))
277 async dismissKnock (root) {
278 await this.base.append(enc({ t: 'dismiss-knock', root: hex(root) }))
258279 }
259280
281 // Live knocks: the PROVEN identity root that knocked, the feed it asked about, and its writer core.
260282 async knocks () {
261283 const map = await this._readKv().list(KNOCKS + '/')
262284 return Object.entries(map)
263285 .filter(([, v]) => v && v.$deleted !== true)
264 .map(([k, v]) => ({ feed: k.slice(KNOCKS.length + 1), device: v.device }))
265 .sort((a, b) => (a.feed < b.feed ? -1 : a.feed > b.feed ? 1 : 0))
286 .map(([k, v]) => ({ root: k.slice(KNOCKS.length + 1), feed: v.feed, device: v.device }))
287 .sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0))
266288 }
267289
268290 async del (collection, name) {
lib/feed.js +42 -18
……@@ -44,6 +44,14 @@ export const KNOCKS = '__knocks__'
4444// collection those records live in, named so the rule below has something to attach to.
4545export const LISTING = 'listing'
4646
47// The identity root a listing was granted to, beside its pointer.
48//
49// ⛔ Not ON the pointer: a log value is EXACTLY { $oid, size } (RecordLog `isPointer`), and anything
50// else makes the record unresolvable on every node. Reserved, so it converges with the listing but can
51// never be materialised as a record. A reader checks the listed feed's founder against it
52// (Peer.directory), which is the half of consent apply cannot see: this log cannot read that feed.
53export const LISTING_ROOT = '__listing_root__'
54
4755// Genesis marker. Set once, by the founding admission, and never read for anything else.
4856//
4957// ⛔ autobee's `host.genesis` looks like it should replace this, and its README says to use it to
……@@ -224,19 +232,33 @@ async function applyDel (view, op, device) {
224232 await view.set(op.collection + SEP + op.name, { $deleted: true })
225233}
226234
227// A knock: record that `op.feed` wants to be seen, and who rang.
235// A knock: an IDENTITY asks to be seen, and names the feed it is asking about.
228236//
229// Keyed by the FEED, not the device, so a flood from one device is one row rather than many — the
230// bound is structural instead of a rate limit, which would have to live inside apply and would make
231// the view non-deterministic.
237// ⛔ THE KNOCK PROVES WHO IS KNOCKING (hyper-social #77, 2026-10-12). It used to record
238// `__knocks__/<op.feed>` for whatever feed the op named, and a registry then read that row as the
239// named feed's consent to be listed — so anyone could knock "as" any feed and have it listed without
240// its owner ever asking. This log cannot read another feed's log, so "this device belongs to feed X"
241// is unprovable here. "This device chains to identity root R" is provable, from the proof's own bytes,
242// exactly as add-device does it: `readProof` binds THIS writer core and returns the root, or null.
243// So the row is keyed by the proven ROOT, it carries the feed the root named, and a READER checks
244// that feed's founder against the root before trusting a listing (Peer.directory).
245//
246// Keyed by the root, so a flood from one identity is one row rather than many: the bound is
247// structural instead of a rate limit, which would have to live inside apply and would make the view
248// non-deterministic.
232249//
233250// No admission check, deliberately: a stranger is the entire point. What bounds it is that this is
234251// the ONLY thing a stranger can do, and it writes one entry in a namespace that can never become a
235252// record.
236
253//
254// ⚠ An unproven knock is now a no-op, including ones already in a log: views are re-derived, so they
255// drop on the next reindex. Measured before shipping: zero listings on any node, one knock (between two
256// of our own feeds).
237257async function applyKnock (view, op, device) {
238258 if (!isSeg(op.feed)) return
239 await view.set(KNOCKS + SEP + op.feed, { device })
259 const proof = readProof(op.proof, device)
260 if (!proof) return
261 await view.set(KNOCKS + SEP + proof.root, { device, feed: op.feed })
240262}
241263
242264// Clearing a knock is the owner's act, so unlike the knock itself it requires admission. Answered
……@@ -244,31 +266,32 @@ async function applyKnock (view, op, device) {
244266// that reported "dismissed" back would be a channel a stranger could probe.
245267async function applyDismissKnock (view, op, device) {
246268 if (!(await isAdmitted(view, device))) return
247 if (!isSeg(op.feed)) return
248 await view.set(KNOCKS + SEP + op.feed, { $deleted: true })
269 if (!isSeg(op.root)) return
270 await view.set(KNOCKS + SEP + op.root, { $deleted: true })
249271}
250272
251// ★ Listing a feed requires TWO acts by TWO parties: an active knock from the feed being listed,
252// and an admitted device of this registry choosing to honour it.
273// ★ Listing a feed requires TWO acts by TWO parties: a live, PROVEN knock from an identity naming
274// this feed, and an admitted device of this registry choosing to honour it.
253275//
254// That makes consent structural. A registry cannot list a feed that never asked, because the knock
255// is written by the knocker's own device and apply can see whether one is present. Without this
256// rule, "opt-in discoverability" would be a promise the registry operator makes rather than a
257// property of the log — and hyper-social #61 §8 requires the two acts, subscribing and being
258// listed, to stay separate.
276// That makes consent structural: a registry cannot list a feed on a knock nobody proved, nor on a knock
277// that named a different feed. hyper-social #61 §8 requires the two acts, subscribing and being listed,
278// to stay separate. The root goes beside the pointer (LISTING_ROOT) so a reader can close the last gap
279// apply cannot: that the listed feed really is that identity's.
259280//
260281// ⚠ What it does NOT give: a delisted feed is not retracted (the listing already replicated), and
261282// an unlisted feed is not private (a feed announces on a topic derived from its key). A registry is
262283// a phone book, not a gate.
263284async function applyListFeed (view, op, device) {
264285 if (!(await isAdmitted(view, device))) return
265 if (!isSeg(op.feed)) return
286 if (!isSeg(op.feed) || !isSeg(op.root)) return
266287 if (!isPointerOp(op)) return
267 const knocked = await view.get(KNOCKS + SEP + op.feed)
288 const knocked = await view.get(KNOCKS + SEP + op.root)
268289 // A dismissed knock is a tombstone, not an absence — consent withdrawn before use is consent
269290 // refused, and it must not read as consent given.
270291 if (!knocked || knocked.$deleted === true) return
292 if (knocked.feed !== op.feed) return // that identity asked about a different feed
271293 await view.set(LISTING + SEP + op.feed, { $oid: op.$oid, size: op.size })
294 await view.set(LISTING_ROOT + SEP + op.feed, { root: op.root })
272295}
273296
274297// Delisting is the registry's act, so it needs admission. A tombstone, like every other deletion
……@@ -277,6 +300,7 @@ async function applyDelist (view, op, device) {
277300 if (!(await isAdmitted(view, device))) return
278301 if (!isSeg(op.feed)) return
279302 await view.set(LISTING + SEP + op.feed, { $deleted: true })
303 await view.set(LISTING_ROOT + SEP + op.feed, { $deleted: true })
280304}
281305
282export default { applyOp, isRosterKey, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING }
306export default { applyOp, isRosterKey, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT }
lib/node.js +7 -0
……@@ -187,6 +187,13 @@ export class FavelaNode {
187187 // and `claim` refuses loudly rather than appending something every peer would silently drop.
188188 this.cred = cred
189189 this.peer = await new Peer(mine).ready()
190 // How this node proves who is knocking: a proof binding a writer core to our identity root.
191 // Reads `this.cred` LIVE, so linking later (`link`) is picked up without rewiring; unlinked, it
192 // refuses rather than letting a knock go out unproven (feed.js applyKnock would drop it anyway).
193 this.peer.bind = (writerKeyHex) => {
194 if (!this.cred) throw new Error('this device is not linked, so it cannot prove who is knocking — run `favela link`')
195 return bindWriter(this.cred, writerKeyHex)
196 }
190197 this.browsers = await new Browsers(this.corestore).ready()
191198 this.avatars = new Avatars(this.storage, this.overlay)
192199 // Files embedded in records — the same overlay, a different directory and a wider type table.
lib/peer.js +45 -7
……@@ -170,6 +170,25 @@ export class Peer {
170170 this.peeked = new Set()
171171 // Feeds I have blocked. Cached like `_follows`, and consulted in ONE place — see `_readable`.
172172 this._blocked = new Set()
173 // ⛔ How a knock proves who is knocking: `bind(writerKeyHex)` → a proof binding that writer core
174 // to this device's identity root. The node sets it from its credential (node.js); unset means
175 // this device is not linked, and a knock refuses rather than going out unproven (feed.js).
176 this.bind = null
177 }
178
179 _binder () {
180 if (typeof this.bind !== 'function') {
181 throw new Error('this device is not linked, so it cannot prove who is knocking — run `favela link`')
182 }
183 return this.bind
184 }
185
186 // The identity root behind a live knock that named `feedKey`, or null. Callers (the CLI, the
187 // surface) still speak in feed keys; a knock is keyed by the root that proved it.
188 async _knockRootFor (feedKey) {
189 const feed = Peer.nameFor(feedKey)
190 for (const k of await this.feed.knocks()) if (k.feed === feed) return k.root
191 return null
173192 }
174193
175194 async ready () {
……@@ -531,7 +550,7 @@ export class Peer {
531550 const theirs = this.feeds.get(t.feed)
532551 if (!theirs || typeof theirs.knock !== 'function') return false // we do not hold it; there is nothing to append to
533552 try {
534 await theirs.knock(this.feed.key)
553 await theirs.knock(this.feed.key, this._binder())
535554 return true
536555 } catch (err) {
537556 try { console.error(JSON.stringify({ msg: 'doorbell: could not ring — they will not learn of this interaction until they read our feed', feed: t.feed, error: (err && err.message) || String(err) })) } catch {}
……@@ -873,14 +892,16 @@ export class Peer {
873892 // Ask a registry to list me. This is a knock, which is to say: the doorbell already IS the consent
874893 // mechanism, and being listed needs no separate protocol.
875894 async askToBeListed (registryFeed, myFeedKey = this.feed.key) {
876 await registryFeed.knock(myFeedKey)
895 await registryFeed.knock(myFeedKey, this._binder())
877896 }
878897
879898 // Honour a request. Listing then dismissing, in that order — apply refuses a listing whose knock
880899 // is already tombstoned.
881900 async listFeed (feedKey, record) {
882 const r = await this.feed.listFeed(feedKey, record)
883 await this.feed.dismissKnock(feedKey)
901 const root = await this._knockRootFor(feedKey)
902 if (!root) throw new Error('no live, proven knock names that feed — it has not asked to be listed')
903 const r = await this.feed.listFeed(root, feedKey, record)
904 await this.feed.dismissKnock(root)
884905 return r
885906 }
886907
……@@ -888,10 +909,23 @@ export class Peer {
888909
889910 // Every listing across the registries I hold, plus my own. Duplicates are kept: the same feed
890911 // listed by two registries is two endorsements, and collapsing them would throw away who vouched.
912 //
913 // ⛔ `verified` closes the half of consent apply cannot see. A listing records the identity root
914 // that knocked; only a reader holding the listed feed can check that the feed's founder IS that
915 // root. true = checked and matches; false = checked and does NOT (treat as not listed); null = we do
916 // not hold that feed, so it is unchecked.
891917 async directory () {
892918 const out = []
893919 for (const [registry, feed] of this._readable()) {
894 for (const l of await feed.listings()) out.push({ registry, ...l })
920 for (const l of await feed.listings()) {
921 let verified = null
922 const listed = this.feeds.get(l.feed)
923 if (listed && typeof listed.founder === 'function') {
924 const founder = await listed.founder()
925 verified = !!(founder && l.root && founder.identityRoot === l.root)
926 }
927 out.push({ registry, ...l, verified })
928 }
895929 }
896930 out.sort((a, b) => {
897931 const ka = a.registry + '/' + a.feed
……@@ -910,11 +944,15 @@ export class Peer {
910944 // and to nobody else.
911945 async admitKnock (feedKey, feed = null) {
912946 const name = await this.follow(feedKey, feed)
913 await this.feed.dismissKnock(feedKey)
947 const root = await this._knockRootFor(feedKey)
948 if (root) await this.feed.dismissKnock(root)
914949 return name
915950 }
916951
917 async dismissKnock (feedKey) { await this.feed.dismissKnock(feedKey) }
952 async dismissKnock (feedKey) {
953 const root = await this._knockRootFor(feedKey)
954 if (root) await this.feed.dismissKnock(root)
955 }
918956
919957 async following () {
920958 const rows = await this.feed.list(FOLLOW)
lib/rpc.js +1 -1
……@@ -292,7 +292,7 @@ export async function dispatch (node, cmd, a) {
292292 return { results: await withLikes(peer, r.results), unreadable: r.unreadable, people: await peer.searchProfiles(a.query) }
293293 }
294294 case 'knocks': return peer.knocks()
295 case 'knock': { await peer.feed.knock(a.feed); return { ok: true } }
295 case 'knock': { await peer.feed.knock(a.feed, peer._binder()); return { ok: true } }
296296 case 'admit': { await peer.admitKnock(a.feed, await node.hold(a.feed)); await peer.update(); return { ok: true } }
297297 case 'dismiss': { await peer.dismissKnock(a.feed); await peer.update(); return { ok: true } }
298298 case 'directory': return peer.directory()
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.59",
3 "version": "0.0.60",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/doorbell-peer.test.js +11 -4
……@@ -14,7 +14,14 @@ import { RecordStore } from '../lib/record-store.js'
1414import { Feed } from '../lib/feed-base.js'
1515import { Peer } from '../lib/peer.js'
1616import { KNOCKS } from '../lib/feed.js'
17import { attest } from './helpers.js'
17import { attest, identity } from './helpers.js'
18
19// ⛔ A knock must prove which identity is knocking (feed.js applyKnock). These tests append it through
20// MY writer as a stand-in for a stranger's, so it carries a proof binding that writer to a separate
21// identity: the knock is then keyed by that identity's root, and still names THEIR feed.
22const KNOCKER_ID = await identity()
23const KNOCKER = await KNOCKER_ID.device()
24const knockOp = (me, them) => Buffer.from(JSON.stringify({ t: 'knock', feed: them.key.toString('hex'), proof: KNOCKER.bind(me.feed.deviceKey) }), 'utf8')
1825
1926const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-bell-'))
2027
……@@ -35,7 +42,7 @@ test('a knock appears at the Peer surface and accepting it FOLLOWS, never admits
3542 // block. So this exercises the doorbell's HANDLING — where a knock lands, what accepting it does —
3643 // and not its authorisation. That a stranger may knock and may do nothing else is proven at the
3744 // unit level in knock.test.js, against a foreign device key.
38 await me.feed.base.append(Buffer.from(JSON.stringify({ t: 'knock', feed: them.key.toString('hex') }), 'utf8'))
45 await me.feed.base.append(knockOp(me, them))
3946 await me.update()
4047
4148 const knocks = await me.knocks()
……@@ -56,7 +63,7 @@ test('dismissing clears without following', async (t) => {
5663 const them = await mkFeed()
5764 t.teardown(() => Promise.all([me.close(), them.close()]))
5865
59 await me.feed.base.append(Buffer.from(JSON.stringify({ t: 'knock', feed: them.key.toString('hex') }), 'utf8'))
66 await me.feed.base.append(knockOp(me, them))
6067 await me.update()
6168 await me.dismissKnock(them.key)
6269 await me.update()
……@@ -70,7 +77,7 @@ test('knocks never leak into the timeline or the record set', async (t) => {
7077 const them = await mkFeed()
7178 t.teardown(() => Promise.all([me.close(), them.close()]))
7279
73 await me.feed.base.append(Buffer.from(JSON.stringify({ t: 'knock', feed: them.key.toString('hex') }), 'utf8'))
80 await me.feed.base.append(knockOp(me, them))
7481 await me.update()
7582
7683 t.alike(await me.timeline(), [], 'no posts')
test/doorbell-wire.test.js +8 -3
……@@ -25,7 +25,12 @@ import { join } from '#node:path'
2525import Corestore from 'corestore'
2626import { RecordStore } from '../lib/record-store.js'
2727import { Feed } from '../lib/feed-base.js'
28import { attest } from './helpers.js'
28import { attest, identity } from './helpers.js'
29
30// ⛔ A knock proves which identity is knocking (feed.js applyKnock): the binder ties the knocking
31// writer core to the stranger's identity root.
32const STRANGER = await (await identity()).device()
33const asStranger = (writerKeyHex) => STRANGER.bind(writerKeyHex)
2934
3035const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-bell-wire-'))
3136const hex = (k) => b4a.toString(k, 'hex')
……@@ -75,7 +80,7 @@ test('★★★ a stranger who is NOT a writer rings the doorbell from another m
7580 await until(async () => { await aliceOnBob.update(); return (await aliceOnBob.devices()).length > 0 })
7681 t.absent(aliceOnBob.writable, '⛔ he is NOT a writer on her feed — that is the whole premise')
7782
78 await aliceOnBob.knock(bob.key)
83 await aliceOnBob.knock(bob.key, asStranger)
7984
8085 const knocks = await until(async () => {
8186 await alice.update()
……@@ -98,7 +103,7 @@ test('⛔ and it grants nothing — a knock is not admission', async (t) => {
98103 t.teardown(async () => { unpipe(); await alice.close(); await bob.close(); await aliceOnBob.close() })
99104
100105 await until(async () => { await aliceOnBob.update(); return (await aliceOnBob.devices()).length > 0 })
101 await aliceOnBob.knock(bob.key)
106 await aliceOnBob.knock(bob.key, asStranger)
102107 await until(async () => { await alice.update(); return (await alice.knocks()).length })
103108
104109 t.alike(await alice.devices(), before, "her writer set is untouched — the thing a doorbell must never change")
test/holder-after-knock.test.js +7 -2
……@@ -23,7 +23,12 @@ import Corestore from 'corestore'
2323import { RecordStore } from '../lib/record-store.js'
2424import { Feed } from '../lib/feed-base.js'
2525import { POST } from '../lib/peer.js'
26import { attest } from './helpers.js'
26import { attest, identity } from './helpers.js'
27
28// ⛔ A knock proves which identity is knocking (feed.js applyKnock): the binder ties the knocking
29// writer core to the stranger's identity root.
30const STRANGER = await (await identity()).device()
31const asStranger = (writerKeyHex) => STRANGER.bind(writerKeyHex)
2732
2833const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-holder-knock-'))
2934
……@@ -69,7 +74,7 @@ async function knockedFeed (t) {
6974 const unpipe = pipe(alice, aliceOnBob)
7075
7176 await until(async () => { await aliceOnBob.update(); return (await aliceOnBob.devices()).length > 0 })
72 await aliceOnBob.knock(bob.key)
77 await aliceOnBob.knock(bob.key, asStranger)
7378 const knocked = await until(async () => { await alice.update(); return (await alice.knocks()).length })
7479 t.ok(knocked, 'setup: the stranger\'s knock landed in the owner\'s feed')
7580
test/interact-doorbell.test.js +4 -2
……@@ -33,7 +33,9 @@ async function mkPeer () {
3333 await f.ready()
3434 await f.addDevice(d.bind(f.deviceKey))
3535 await f.update()
36 return new Peer(f).ready()
36 const p = await new Peer(f).ready()
37 p.bind = (writerKeyHex) => d.bind(writerKeyHex) // how this peer proves who is knocking (node.js sets it in prod)
38 return p
3739}
3840
3941// Bob holds Alice's feed — he must, to have seen the post he is about to like.
……@@ -94,7 +96,7 @@ test('⛔ undoing does not ring — a retraction is not an approach', async (t)
9496 const { alice, bob, target } = await pair()
9597 await bob.like(target)
9698 await alice.update()
97 await alice.feed.dismissKnock(bob.feed.key)
99 await alice.dismissKnock(bob.feed.key) // a knock is keyed by root; the peer maps bob's feed to it
98100 await alice.update()
99101 t.is((await alice.feed.knocks()).length, 0, 'she dismissed it')
100102
test/knock-identity.test.js +112 -0
……@@ -0,0 +1,112 @@
1// ⛔ A knock proves WHO is knocking: an identity root, never a bare feed key.
2//
3// THE DEFECT (hyper-social #77 §1, 2026-10-12). `applyKnock` recorded `__knocks__/<op.feed>` for
4// whatever feed the op named, and `applyListFeed` read that row as the named feed's CONSENT to be
5// listed. Nothing tied the device that wrote the knock to the feed it named, so anyone holding a
6// registry's log could knock "as" any feed and get it listed without its owner ever asking. The
7// comment on applyListFeed claimed the opposite ("the knock is written by the knocker's own device").
8//
9// Why the fix names a ROOT and not a feed: inside apply, this log cannot read another feed's log, so
10// "this device belongs to feed X" cannot be proven here. "This device chains to identity root R" can,
11// from the proof's own bytes, exactly as add-device does. A feed key is a locator; the identity root is
12// who (favela-markdown `uri.js`: the authority is the identity root). So the knock is keyed by the
13// proven root, a listing records that root, and a READER checks the listed feed's founder against it
14// (Peer.directory).
15
16import test from 'brittle'
17import { identity } from './helpers.js'
18import { applyOp, isRosterKey, KNOCKS, LISTING, LISTING_ROOT } from '../lib/feed.js'
19import { proofsFor } from './helpers.js'
20
21const kv = () => {
22 const m = new Map()
23 return {
24 m,
25 async set (k, v) { m.set(k, v) },
26 async get (k) { return m.has(k) ? m.get(k) : null },
27 entries () { return [...m.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) }
28 }
29}
30
31const OWNER = 'd1'.repeat(32) // the registry's own device
32const STRANGER = 'ff'.repeat(32) // the knocking WRITER core, on the registry's log
33const OTHER_WRITER = 'ee'.repeat(32)
34const THEIR_FEED = 'ab'.repeat(32)
35const VICTIM_FEED = 'cd'.repeat(32)
36
37const PROOF = await proofsFor([OWNER])
38const STRANGER_ID = await identity()
39const STRANGER_DEVICE = await STRANGER_ID.device()
40const STRANGER_ROOT = STRANGER_ID.rootHex
41
42const addDevice = (device) => ({ t: 'add-device', device, proof: PROOF[device] })
43const run = async (ops, view = kv()) => {
44 for (const [device, op] of ops) {
45 const genesis = !view.entries().some(([k]) => isRosterKey(k))
46 await applyOp(view, op, { device, genesis })
47 }
48 return view
49}
50const knocksIn = (v) => v.entries().filter(([k]) => k.startsWith(KNOCKS))
51
52test('⛔ an unproven knock records nothing', async (t) => {
53 const v = await run([[OWNER, addDevice(OWNER)], [STRANGER, { t: 'knock', feed: VICTIM_FEED }]])
54 t.alike(knocksIn(v), [], 'no proof, no knock — a bare feed key is not consent from that feed')
55})
56
57test('a proven knock is keyed by the knocker\'s identity root and names its feed', async (t) => {
58 const v = await run([
59 [OWNER, addDevice(OWNER)],
60 [STRANGER, { t: 'knock', feed: THEIR_FEED, proof: STRANGER_DEVICE.bind(STRANGER) }]
61 ])
62 t.alike(await v.get(KNOCKS + '/' + STRANGER_ROOT), { device: STRANGER, feed: THEIR_FEED },
63 'the row is the proven root: who knocked, with the feed they asked about')
64})
65
66test('⛔ a proof bound to a different writer is refused', async (t) => {
67 const v = await run([
68 [OWNER, addDevice(OWNER)],
69 [STRANGER, { t: 'knock', feed: THEIR_FEED, proof: STRANGER_DEVICE.bind(OTHER_WRITER) }]
70 ])
71 t.alike(knocksIn(v), [], 'a proof is never a bearer token for another core')
72})
73
74test('knocking twice is one row per identity, the latest feed named', async (t) => {
75 const v = await run([
76 [OWNER, addDevice(OWNER)],
77 [STRANGER, { t: 'knock', feed: THEIR_FEED, proof: STRANGER_DEVICE.bind(STRANGER) }],
78 [STRANGER, { t: 'knock', feed: THEIR_FEED, proof: STRANGER_DEVICE.bind(STRANGER) }]
79 ])
80 t.is(knocksIn(v).length, 1, 'keyed by root, so a flood is one row')
81})
82
83test('⛔ a listing needs a proven knock from that root naming that same feed', async (t) => {
84 const knock = [STRANGER, { t: 'knock', feed: THEIR_FEED, proof: STRANGER_DEVICE.bind(STRANGER) }]
85 const list = (feed) => [OWNER, { t: 'list-feed', root: STRANGER_ROOT, feed, $oid: 'o', size: 1 }]
86
87 const wrongFeed = await run([[OWNER, addDevice(OWNER)], knock, list(VICTIM_FEED)])
88 t.is(await wrongFeed.get(LISTING + '/' + VICTIM_FEED), null, 'the root asked about ITS feed, not this one')
89
90 const ok = await run([[OWNER, addDevice(OWNER)], knock, list(THEIR_FEED)])
91 t.alike(await ok.get(LISTING + '/' + THEIR_FEED), { $oid: 'o', size: 1 }, 'listed: the pointer stays exactly { $oid, size }')
92 t.alike(await ok.get(LISTING_ROOT + '/' + THEIR_FEED), { root: STRANGER_ROOT },
93 'and the root is recorded beside it, so a reader can check the feed\'s founder')
94})
95
96test('⛔ the old spoof: a feed listed on a knock it never wrote', async (t) => {
97 // The exact attack: knock naming the victim's feed, then a registry lists it as consent.
98 const v = await run([
99 [OWNER, addDevice(OWNER)],
100 [STRANGER, { t: 'knock', feed: VICTIM_FEED }],
101 [OWNER, { t: 'list-feed', feed: VICTIM_FEED, $oid: 'o', size: 1 }]
102 ])
103 t.is(await v.get(LISTING + '/' + VICTIM_FEED), null, 'refused: nobody proved they own the victim\'s feed')
104})
105
106test('dismissing is by root, and needs admission', async (t) => {
107 const knock = [STRANGER, { t: 'knock', feed: THEIR_FEED, proof: STRANGER_DEVICE.bind(STRANGER) }]
108 const v = await run([[OWNER, addDevice(OWNER)], knock, [STRANGER, { t: 'dismiss-knock', root: STRANGER_ROOT }]])
109 t.ok(await v.get(KNOCKS + '/' + STRANGER_ROOT), 'a stranger cannot clear a knock')
110 await applyOp(v, { t: 'dismiss-knock', root: STRANGER_ROOT }, { device: OWNER, genesis: false })
111 t.alike(await v.get(KNOCKS + '/' + STRANGER_ROOT), { $deleted: true }, 'the owner can')
112})
test/knock.test.js +20 -15
……@@ -42,16 +42,20 @@ const run = async (ops, view = kv()) => {
4242const PROOF = await proofsFor([D1, THEIR_FEED])
4343// ⛔ A SEPARATE identity — a stranger is someone else's device, not a co-owner. Minting it
4444// from the same root would make every "a stranger cannot" test pass for the wrong reason.
45const OUTSIDER = await (await identity()).device()
45const OUTSIDER_ID = await identity()
46const OUTSIDER = await OUTSIDER_ID.device()
47const OUTSIDER_ROOT = OUTSIDER_ID.rootHex
4648const STRANGER_PROOF = OUTSIDER.bind(STRANGER)
4749const addDevice = (device) => ({ t: 'add-device', device, proof: device === STRANGER ? STRANGER_PROOF : PROOF[device] })
48const knock = (feed) => ({ t: 'knock', feed })
50// ⛔ A knock carries a proof binding the KNOCKING writer core to its identity root (feed.js), and is
51// keyed by that root. The stranger's proof chains to OUTSIDER's root; the owner's to PROOF.__root.
52const knock = (feed, writer = STRANGER) => ({ t: 'knock', feed, proof: writer === STRANGER ? STRANGER_PROOF : PROOF[writer] })
4953const put = (collection, name) => ({ t: 'put', collection, name, $oid: 'o', size: 1 })
5054
5155test('a stranger may knock, and it is recorded', async (t) => {
5256 const v = await run([[D1, addDevice(D1)], [STRANGER, knock(THEIR_FEED)]])
53 t.alike(await v.get(KNOCKS + '/' + THEIR_FEED), { device: STRANGER },
54 'the knock names the feed, and who rang')
57 t.alike(await v.get(KNOCKS + '/' + OUTSIDER_ROOT), { device: STRANGER, feed: THEIR_FEED },
58 'keyed by the identity that proved it rang, naming the feed it asked about')
5559})
5660
5761test('⛔ knocking grants NOTHING — not a writer, not a device', async (t) => {
……@@ -63,15 +67,16 @@ test('⛔ knocking grants NOTHING — not a writer, not a device', async (t) =>
6367 ])
6468 t.is(await v.get('post/x'), null, 'a knocker still cannot write a record')
6569 t.is(await v.get(ROSTER + '/' + STRANGER), null, 'nor admit itself')
66 t.ok(await v.get(KNOCKS + '/' + THEIR_FEED), 'only the knock landed')
70 t.ok(await v.get(KNOCKS + '/' + OUTSIDER_ROOT), 'only the knock landed')
6771})
6872
6973test('⛔ a knock cannot forge another feed\'s entry or reach outside the namespace', async (t) => {
7074 const v = await run([
7175 [D1, addDevice(D1)],
72 [STRANGER, { t: 'knock' }],
73 [STRANGER, { t: 'knock', feed: 'not/a/segment' }],
74 [STRANGER, { t: 'knock', feed: '' }]
76 // A valid proof on each, so these test the FEED validation, not a missing proof.
77 [STRANGER, { t: 'knock', proof: STRANGER_PROOF }],
78 [STRANGER, { t: 'knock', feed: 'not/a/segment', proof: STRANGER_PROOF }],
79 [STRANGER, { t: 'knock', feed: '', proof: STRANGER_PROOF }]
7580 ])
7681 t.alike(v.entries().filter(([k]) => k.startsWith(KNOCKS)), [], 'malformed knocks write nothing')
7782})
……@@ -79,12 +84,12 @@ test('⛔ a knock cannot forge another feed\'s entry or reach outside the namesp
7984test('knocking twice is one knock', async (t) => {
8085 const v = await run([[D1, addDevice(D1)], [STRANGER, knock(THEIR_FEED)], [STRANGER, knock(THEIR_FEED)]])
8186 t.is(v.entries().filter(([k]) => k.startsWith(KNOCKS)).length, 1,
82 'the entry is keyed by the feed, so a flood is one row')
87 'the entry is keyed by the identity root, so a flood is one row')
8388})
8489
8590test('an admitted device may knock too — it is not an unadmitted-only op', async (t) => {
86 const v = await run([[D1, addDevice(D1)], [D1, knock(THEIR_FEED)]])
87 t.alike(await v.get(KNOCKS + '/' + THEIR_FEED), { device: D1 })
91 const v = await run([[D1, addDevice(D1)], [D1, knock(THEIR_FEED, D1)]])
92 t.alike(await v.get(KNOCKS + '/' + PROOF.__root), { device: D1, feed: THEIR_FEED })
8893})
8994
9095test('⛔ a knock is not a record — it never appears in the record set', async (t) => {
……@@ -107,11 +112,11 @@ test('dismissing a knock requires admission', async (t) => {
107112 const v = await run([
108113 [D1, addDevice(D1)],
109114 [STRANGER, knock(THEIR_FEED)],
110 [STRANGER, { t: 'dismiss-knock', feed: THEIR_FEED }]
115 [STRANGER, { t: 'dismiss-knock', root: OUTSIDER_ROOT }]
111116 ])
112 t.alike(await v.get(KNOCKS + '/' + THEIR_FEED), { device: STRANGER },
117 t.alike(await v.get(KNOCKS + '/' + OUTSIDER_ROOT), { device: STRANGER, feed: THEIR_FEED },
113118 'a stranger cannot clear its own knock — nor anyone else\'s')
114119
115 await applyOp(v, { t: 'dismiss-knock', feed: THEIR_FEED }, { device: D1, genesis: false })
116 t.alike(await v.get(KNOCKS + '/' + THEIR_FEED), { $deleted: true }, 'the owner can')
120 await applyOp(v, { t: 'dismiss-knock', root: OUTSIDER_ROOT }, { device: D1, genesis: false })
121 t.alike(await v.get(KNOCKS + '/' + OUTSIDER_ROOT), { $deleted: true }, 'the owner can')
117122})
test/notifications.test.js +7 -2
……@@ -31,7 +31,9 @@ async function mkPeer () {
3131 await f.ready()
3232 await f.addDevice(d.bind(f.deviceKey))
3333 await f.update()
34 return { peer: await new Peer(f).ready(), root: id.rootHex }
34 const peer = await new Peer(f).ready()
35 peer.bind = (writerKeyHex) => d.bind(writerKeyHex) // how this peer proves who is knocking (node.js sets it in prod)
36 return { peer, root: id.rootHex }
3537}
3638
3739// me, holding them — the ordinary case, and the only one that can produce a notification.
……@@ -109,7 +111,10 @@ test('★ the DOORBELL is in the list — the one thing a stranger can do', asyn
109111 // distinction is load-bearing: a knock from a held feed is a SECOND notification for an act that
110112 // already produced one, so it is skipped, and this test would have been the thing that noticed.
111113 const stranger = 'ab'.repeat(32)
112 await me.peer.feed.knock(Buffer.from(stranger, 'hex'))
114 // A knock proves which identity is knocking: here a stranger's device, binding the writer core it
115 // knocks through (written via my writer, as there is no transport in this test).
116 const strangerDevice = await (await identity()).device()
117 await me.peer.feed.knock(Buffer.from(stranger, 'hex'), (writerKeyHex) => strangerDevice.bind(writerKeyHex))
113118 await me.peer.update()
114119
115120 const knocks = (await me.peer.notifications()).filter((n) => n.kind === 'knock')
test/registry-peer.test.js +66 -12
……@@ -8,7 +8,7 @@ import Corestore from 'corestore'
88import { RecordStore } from '../lib/record-store.js'
99import { Feed } from '../lib/feed-base.js'
1010import { Peer } from '../lib/peer.js'
11import { attest } from './helpers.js'
11import { attest, identity } from './helpers.js'
1212
1313const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-reg-'))
1414const rec = (body) => ({ frontmatter: { createdAt: '2026-09-06T00:00:00Z' }, body })
……@@ -22,14 +22,26 @@ async function mkFeed () {
2222}
2323const mkPeer = async () => new Peer(await mkFeed()).ready()
2424
25// A feed owned by a REAL identity whose device we keep, so a knock can prove it is that identity
26// asking (feed.js applyKnock) and a reader can check the listed feed's founder against the root.
27async function mkOwned () {
28 const dev = await (await identity()).device()
29 const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(tmp()), optimistic: true })
30 await f.ready()
31 await f.addDevice(dev.bind(f.deviceKey))
32 await f.update()
33 f.asOwner = (writerKeyHex) => dev.bind(writerKeyHex) // their binder
34 return f
35}
36
2537test('ask, honour, browse — and the knock is cleared by being honoured', async (t) => {
2638 const registry = await mkPeer()
27 const them = await mkFeed()
39 const them = await mkOwned()
2840 t.teardown(() => Promise.all([registry.close(), them.close()]))
2941
30 // The ask is a knock on the registry. Here the registry rings its own bell on their behalf,
31 // because there is no transport yet — the RULE it exercises is the same one apply enforces.
32 await registry.feed.knock(them.key)
42 // The ask is a knock on the registry, written through the registry's writer here (no transport),
43 // but PROVEN with THEIR device: the rule it exercises is the same one apply enforces.
44 await registry.feed.knock(them.key, them.asOwner)
3345 await registry.update()
3446 t.is((await registry.knocks()).length, 1, 'the request is waiting')
3547
……@@ -48,7 +60,10 @@ test('⛔ a registry cannot list a feed that never asked', async (t) => {
4860 const them = await mkFeed()
4961 t.teardown(() => Promise.all([registry.close(), them.close()]))
5062
51 await registry.listFeed(them.key, rec('unsolicited'))
63 // Refused up front at the Peer surface: there is no proven knock naming that feed, so there is no
64 // root to grant the listing to. (apply refuses it independently too; registry.test.js.)
65 await t.exception(registry.listFeed(them.key, rec('unsolicited')), /no live, proven knock/,
66 'refused, rather than appending an op apply would silently ignore')
5267 await registry.update()
5368
5469 t.alike(await registry.directory(), [], 'consent is structural, not a convention')
……@@ -57,10 +72,10 @@ test('⛔ a registry cannot list a feed that never asked', async (t) => {
5772test('subscribing to a registry surfaces its listings, without being in it', async (t) => {
5873 const me = await mkPeer()
5974 const registry = await mkPeer()
60 const them = await mkFeed()
75 const them = await mkOwned()
6176 t.teardown(() => Promise.all([me.close(), registry.close(), them.close()]))
6277
63 await registry.feed.knock(them.key)
78 await registry.feed.knock(them.key, them.asOwner)
6479 await registry.update()
6580 await registry.listFeed(them.key, rec('listed'))
6681 await registry.update()
……@@ -77,10 +92,10 @@ test('subscribing to a registry surfaces its listings, without being in it', asy
7792
7893test('delisting removes it from the directory but is not a retraction', async (t) => {
7994 const registry = await mkPeer()
80 const them = await mkFeed()
95 const them = await mkOwned()
8196 t.teardown(() => Promise.all([registry.close(), them.close()]))
8297
83 await registry.feed.knock(them.key)
98 await registry.feed.knock(them.key, them.asOwner)
8499 await registry.update()
85100 await registry.listFeed(them.key, rec('listed'))
86101 await registry.update()
……@@ -96,11 +111,11 @@ test('the same feed listed by two registries is two endorsements, not one', asyn
96111 const me = await mkPeer()
97112 const r1 = await mkPeer()
98113 const r2 = await mkPeer()
99 const them = await mkFeed()
114 const them = await mkOwned()
100115 t.teardown(() => Promise.all([me.close(), r1.close(), r2.close(), them.close()]))
101116
102117 for (const r of [r1, r2]) {
103 await r.feed.knock(them.key)
118 await r.feed.knock(them.key, them.asOwner)
104119 await r.update()
105120 await r.listFeed(them.key, rec('listed'))
106121 await r.update()
……@@ -113,3 +128,42 @@ test('the same feed listed by two registries is two endorsements, not one', asyn
113128 t.is(dir.length, 2, 'both kept — collapsing them would throw away who vouched')
114129 t.alike(new Set(dir.map((x) => x.feed)).size, 1, 'for one feed')
115130})
131
132// ⛔⛔ THE READER'S HALF OF CONSENT (hyper-social #77). Apply can prove WHICH identity knocked, but it
133// cannot read the listed feed, so it cannot prove that identity OWNS the feed it named. A reader that
134// holds the feed can: its founder root must equal the root the listing was granted to.
135test('⛔ a listing is verified against the listed feed\'s founder, and a spoofer\'s fails', async (t) => {
136 const me = await mkPeer()
137 const registry = await mkPeer()
138 const them = await mkOwned()
139 const spoofer = await mkOwned() // a real identity, asking about a feed that is NOT its own
140 t.teardown(() => Promise.all([me.close(), registry.close(), them.close(), spoofer.close()]))
141
142 // The genuine ask: their identity, naming their feed.
143 await registry.feed.knock(them.key, them.asOwner)
144 await registry.update()
145 await registry.listFeed(them.key, rec('genuine'))
146 await registry.update()
147
148 await me.follow(registry.feed.key, registry.feed)
149 await me.follow(them.key, them) // I hold their feed, so I can check its founder
150 await me.update()
151 let dir = await me.directory()
152 t.is(dir.length, 1)
153 t.is(dir[0].verified, true, 'their identity knocked, and their feed\'s founder is that identity')
154
155 // The spoof: a different identity knocks NAMING their feed, and a careless registry lists it.
156 const victim = await mkOwned()
157 t.teardown(() => victim.close())
158 await registry.feed.knock(victim.key, spoofer.asOwner)
159 await registry.update()
160 await registry.listFeed(victim.key, rec('spoofed'))
161 await registry.update()
162 await me.follow(victim.key, victim)
163 await me.update()
164
165 dir = await me.directory()
166 const spoofed = dir.find((d) => d.feed === victim.key.toString('hex'))
167 t.ok(spoofed, 'apply let it through: it cannot read the victim\'s feed')
168 t.is(spoofed.verified, false, '⛔ but a reader holding the feed sees its founder is NOT the identity that asked')
169})
test/registry.test.js +17 -12
……@@ -1,9 +1,10 @@
11// The registry — a feed whose records are listings of other feeds. No new primitive.
22//
3// ★ Consent is STRUCTURAL, not conventional. A listing may only be written for a feed that has an
4// active knock in this same registry, and a knock is written by the knocker's own device. So
5// "asking to be listed" and "being listed" are two acts by two parties, and apply enforces it
6// rather than a convention someone has to remember.
3// ★ Consent is STRUCTURAL, not conventional. A listing may only be written for a feed that a live,
4// PROVEN knock in this same registry named, and the knock proves which identity root wrote it
5// (feed.js applyKnock; knock-identity.test.js). So "asking to be listed" and "being listed" are two
6// acts by two parties, and apply enforces it rather than a convention someone has to remember. A
7// reader then checks the listed feed's founder against the granted root (registry-peer.test.js).
78//
89// Two honesty limits, both from hyper-social #61 §8 and neither fixable here:
910// Delisting stops propagation; it does not retract. The listing already replicated.
……@@ -12,7 +13,7 @@
1213
1314import test from 'brittle'
1415import { proofsFor } from './helpers.js'
15import { applyOp, isRosterKey, KNOCKS } from '../lib/feed.js'
16import { applyOp, isRosterKey, KNOCKS, LISTING_ROOT } from '../lib/feed.js'
1617
1718const kv = () => {
1819 const m = new Map()
……@@ -40,14 +41,17 @@ const run = async (ops, view = kv()) => {
4041// One owner's devices. Minted once so every call site stays synchronous.
4142const PROOF = await proofsFor([OWNER, OTHER, FEED_A, FEED_B])
4243const addDevice = (d) => ({ t: 'add-device', device: d, proof: PROOF[d] })
43const knock = (feed) => ({ t: 'knock', feed })
44const listFeed = (feed) => ({ t: 'list-feed', feed, $oid: 'oid-' + feed, size: 10 })
44// OTHER is the knocking writer core; its proof chains to ROOT, so the knock is keyed by ROOT.
45const ROOT = PROOF.__root
46const knock = (feed) => ({ t: 'knock', feed, proof: PROOF[OTHER] })
47const listFeed = (feed) => ({ t: 'list-feed', root: ROOT, feed, $oid: 'oid-' + feed, size: 10 })
4548const delist = (feed) => ({ t: 'delist', feed })
4649
4750test('a knock then a listing — two parties, two acts', async (t) => {
4851 const v = await run([[OWNER, addDevice(OWNER)], [OTHER, knock(FEED_A)], [OWNER, listFeed(FEED_A)]])
4952 t.alike(await v.get('listing/' + FEED_A), { $oid: 'oid-' + FEED_A, size: 10 },
5053 'the listing is an ordinary pointer, in an ordinary collection')
54 t.alike(await v.get(LISTING_ROOT + '/' + FEED_A), { root: ROOT }, 'and the root it was granted to sits beside it')
5155})
5256
5357test('⛔ a listing with NO knock is refused — consent is not optional', async (t) => {
……@@ -58,7 +62,7 @@ test('⛔ a listing with NO knock is refused — consent is not optional', async
5862test('⛔ a knock alone does not list you', async (t) => {
5963 const v = await run([[OWNER, addDevice(OWNER)], [OTHER, knock(FEED_A)]])
6064 t.is(await v.get('listing/' + FEED_A), null, 'asking is not being listed')
61 t.ok(await v.get(KNOCKS + '/' + FEED_A), 'the request is waiting')
65 t.ok(await v.get(KNOCKS + '/' + ROOT), 'the request is waiting, keyed by the identity that asked')
6266})
6367
6468test('⛔ a stranger cannot list itself even after knocking', async (t) => {
……@@ -70,7 +74,7 @@ test('a dismissed knock cannot be used to list', async (t) => {
7074 const v = await run([
7175 [OWNER, addDevice(OWNER)],
7276 [OTHER, knock(FEED_A)],
73 [OWNER, { t: 'dismiss-knock', feed: FEED_A }],
77 [OWNER, { t: 'dismiss-knock', root: ROOT }],
7478 [OWNER, listFeed(FEED_A)]
7579 ])
7680 t.is(await v.get('listing/' + FEED_A), null, 'consent withdrawn before use is consent refused')
……@@ -110,8 +114,9 @@ test('a registry holds many listings, each independent', async (t) => {
110114
111115test('a malformed listing writes nothing and does not throw', async (t) => {
112116 const v = await run([[OWNER, addDevice(OWNER)], [OTHER, knock(FEED_A)],
113 [OWNER, { t: 'list-feed', feed: FEED_A }],
114 [OWNER, { t: 'list-feed', $oid: 'x', size: 1 }],
115 [OWNER, { t: 'list-feed', feed: 'not/a/segment', $oid: 'x', size: 1 }]])
117 // A valid root on each, so these test the missing pointer / feed they were written for.
118 [OWNER, { t: 'list-feed', root: ROOT, feed: FEED_A }],
119 [OWNER, { t: 'list-feed', root: ROOT, $oid: 'x', size: 1 }],
120 [OWNER, { t: 'list-feed', root: ROOT, feed: 'not/a/segment', $oid: 'x', size: 1 }]])
116121 t.is(await v.get('listing/' + FEED_A), null, 'a listing without a pointer is not a listing')
117122})