| … | … | @@ -157,7 +157,7 @@ nothing on this side can send one yet. The bridge half is built; see hyper-socia |
| 157 | 157 | |
| 158 | 158 | ## Status |
| 159 | 159 | |
| 160 | Working. `npm test` — 397 tests, 943 asserts, passing (`brittle-bare test/*.test.js`, 50 files, | |
| 160 | Working. `npm test` — 405 tests, 959 asserts, passing (`brittle-bare test/*.test.js`, 50 files, | |
| 161 | 161 | ~43 s). |
| 162 | 162 | |
| 163 | 163 | Deployed as a [pgh](../pgh) app. Measured 2026-09-20: 0.0.57 installed and running on tabertron and |
| … | … | @@ -25,7 +25,7 @@ |
| 25 | 25 | |
| 26 | 26 | import Autobee from 'autobee' |
| 27 | 27 | import b4a from 'b4a' |
| 28 | import { applyOp, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING } from './feed.js' | |
| 28 | import { applyOp, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT } from './feed.js' | |
| 29 | 29 | import { RecordLog } from './log.js' |
| 30 | 30 | |
| 31 | 31 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| … | … | @@ -228,8 +228,18 @@ export class Feed { |
| 228 | 228 | // |
| 229 | 229 | // ⛔ `base.writable` GOES TRUE ON THE APPENDER after an optimistic append — it means "I have a |
| 230 | 230 | // 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 }) | |
| 233 | 243 | } |
| 234 | 244 | |
| 235 | 245 | // 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 { |
| 237 | 247 | // |
| 238 | 248 | // ⚠ apply refuses this unless an active knock exists for the feed, so listing something that never |
| 239 | 249 | // 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) { | |
| 241 | 254 | const feed = hex(feedKey) |
| 242 | 255 | 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 })) | |
| 244 | 257 | return { oid, size } |
| 245 | 258 | } |
| 246 | 259 | |
| … | … | @@ -249,20 +262,29 @@ export class Feed { |
| 249 | 262 | try { await this.store.del(LISTING, hex(feedKey)) } catch {} |
| 250 | 263 | } |
| 251 | 264 | |
| 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. | |
| 252 | 267 | 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 | |
| 254 | 275 | } |
| 255 | 276 | |
| 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) })) | |
| 258 | 279 | } |
| 259 | 280 | |
| 281 | // Live knocks: the PROVEN identity root that knocked, the feed it asked about, and its writer core. | |
| 260 | 282 | async knocks () { |
| 261 | 283 | const map = await this._readKv().list(KNOCKS + '/') |
| 262 | 284 | return Object.entries(map) |
| 263 | 285 | .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)) | |
| 266 | 288 | } |
| 267 | 289 | |
| 268 | 290 | async del (collection, name) { |
| … | … | @@ -44,6 +44,14 @@ export const KNOCKS = '__knocks__' |
| 44 | 44 | // collection those records live in, named so the rule below has something to attach to. |
| 45 | 45 | export const LISTING = 'listing' |
| 46 | 46 | |
| 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. | |
| 53 | export const LISTING_ROOT = '__listing_root__' | |
| 54 | ||
| 47 | 55 | // Genesis marker. Set once, by the founding admission, and never read for anything else. |
| 48 | 56 | // |
| 49 | 57 | // ⛔ 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) { |
| 224 | 232 | await view.set(op.collection + SEP + op.name, { $deleted: true }) |
| 225 | 233 | } |
| 226 | 234 | |
| 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. | |
| 228 | 236 | // |
| 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. | |
| 232 | 249 | // |
| 233 | 250 | // No admission check, deliberately: a stranger is the entire point. What bounds it is that this is |
| 234 | 251 | // the ONLY thing a stranger can do, and it writes one entry in a namespace that can never become a |
| 235 | 252 | // 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). | |
| 237 | 257 | async function applyKnock (view, op, device) { |
| 238 | 258 | 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 }) | |
| 240 | 262 | } |
| 241 | 263 | |
| 242 | 264 | // 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) { |
| 244 | 266 | // that reported "dismissed" back would be a channel a stranger could probe. |
| 245 | 267 | async function applyDismissKnock (view, op, device) { |
| 246 | 268 | 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 }) | |
| 249 | 271 | } |
| 250 | 272 | |
| 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. | |
| 253 | 275 | // |
| 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. | |
| 259 | 280 | // |
| 260 | 281 | // ⚠ What it does NOT give: a delisted feed is not retracted (the listing already replicated), and |
| 261 | 282 | // an unlisted feed is not private (a feed announces on a topic derived from its key). A registry is |
| 262 | 283 | // a phone book, not a gate. |
| 263 | 284 | async function applyListFeed (view, op, device) { |
| 264 | 285 | if (!(await isAdmitted(view, device))) return |
| 265 | if (!isSeg(op.feed)) return | |
| 286 | if (!isSeg(op.feed) || !isSeg(op.root)) return | |
| 266 | 287 | if (!isPointerOp(op)) return |
| 267 | const knocked = await view.get(KNOCKS + SEP + op.feed) | |
| 288 | const knocked = await view.get(KNOCKS + SEP + op.root) | |
| 268 | 289 | // A dismissed knock is a tombstone, not an absence — consent withdrawn before use is consent |
| 269 | 290 | // refused, and it must not read as consent given. |
| 270 | 291 | if (!knocked || knocked.$deleted === true) return |
| 292 | if (knocked.feed !== op.feed) return // that identity asked about a different feed | |
| 271 | 293 | 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 }) | |
| 272 | 295 | } |
| 273 | 296 | |
| 274 | 297 | // 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) { |
| 277 | 300 | if (!(await isAdmitted(view, device))) return |
| 278 | 301 | if (!isSeg(op.feed)) return |
| 279 | 302 | await view.set(LISTING + SEP + op.feed, { $deleted: true }) |
| 303 | await view.set(LISTING_ROOT + SEP + op.feed, { $deleted: true }) | |
| 280 | 304 | } |
| 281 | 305 | |
| 282 | export default { applyOp, isRosterKey, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING } | |
| 306 | export default { applyOp, isRosterKey, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT } |
| … | … | @@ -187,6 +187,13 @@ export class FavelaNode { |
| 187 | 187 | // and `claim` refuses loudly rather than appending something every peer would silently drop. |
| 188 | 188 | this.cred = cred |
| 189 | 189 | 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 | } | |
| 190 | 197 | this.browsers = await new Browsers(this.corestore).ready() |
| 191 | 198 | this.avatars = new Avatars(this.storage, this.overlay) |
| 192 | 199 | // Files embedded in records — the same overlay, a different directory and a wider type table. |
| … | … | @@ -170,6 +170,25 @@ export class Peer { |
| 170 | 170 | this.peeked = new Set() |
| 171 | 171 | // Feeds I have blocked. Cached like `_follows`, and consulted in ONE place — see `_readable`. |
| 172 | 172 | 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 | |
| 173 | 192 | } |
| 174 | 193 | |
| 175 | 194 | async ready () { |
| … | … | @@ -531,7 +550,7 @@ export class Peer { |
| 531 | 550 | const theirs = this.feeds.get(t.feed) |
| 532 | 551 | if (!theirs || typeof theirs.knock !== 'function') return false // we do not hold it; there is nothing to append to |
| 533 | 552 | try { |
| 534 | await theirs.knock(this.feed.key) | |
| 553 | await theirs.knock(this.feed.key, this._binder()) | |
| 535 | 554 | return true |
| 536 | 555 | } catch (err) { |
| 537 | 556 | 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 { |
| 873 | 892 | // Ask a registry to list me. This is a knock, which is to say: the doorbell already IS the consent |
| 874 | 893 | // mechanism, and being listed needs no separate protocol. |
| 875 | 894 | async askToBeListed (registryFeed, myFeedKey = this.feed.key) { |
| 876 | await registryFeed.knock(myFeedKey) | |
| 895 | await registryFeed.knock(myFeedKey, this._binder()) | |
| 877 | 896 | } |
| 878 | 897 | |
| 879 | 898 | // Honour a request. Listing then dismissing, in that order — apply refuses a listing whose knock |
| 880 | 899 | // is already tombstoned. |
| 881 | 900 | 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) | |
| 884 | 905 | return r |
| 885 | 906 | } |
| 886 | 907 | |
| … | … | @@ -888,10 +909,23 @@ export class Peer { |
| 888 | 909 | |
| 889 | 910 | // Every listing across the registries I hold, plus my own. Duplicates are kept: the same feed |
| 890 | 911 | // 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. | |
| 891 | 917 | async directory () { |
| 892 | 918 | const out = [] |
| 893 | 919 | 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 | } | |
| 895 | 929 | } |
| 896 | 930 | out.sort((a, b) => { |
| 897 | 931 | const ka = a.registry + '/' + a.feed |
| … | … | @@ -910,11 +944,15 @@ export class Peer { |
| 910 | 944 | // and to nobody else. |
| 911 | 945 | async admitKnock (feedKey, feed = null) { |
| 912 | 946 | 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) | |
| 914 | 949 | return name |
| 915 | 950 | } |
| 916 | 951 | |
| 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 | } | |
| 918 | 956 | |
| 919 | 957 | async following () { |
| 920 | 958 | const rows = await this.feed.list(FOLLOW) |
| … | … | @@ -292,7 +292,7 @@ export async function dispatch (node, cmd, a) { |
| 292 | 292 | return { results: await withLikes(peer, r.results), unreadable: r.unreadable, people: await peer.searchProfiles(a.query) } |
| 293 | 293 | } |
| 294 | 294 | 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 } } | |
| 296 | 296 | case 'admit': { await peer.admitKnock(a.feed, await node.hold(a.feed)); await peer.update(); return { ok: true } } |
| 297 | 297 | case 'dismiss': { await peer.dismissKnock(a.feed); await peer.update(); return { ok: true } } |
| 298 | 298 | case 'directory': return peer.directory() |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.59", | |
| 3 | "version": "0.0.60", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -14,7 +14,14 @@ import { RecordStore } from '../lib/record-store.js' |
| 14 | 14 | import { Feed } from '../lib/feed-base.js' |
| 15 | 15 | import { Peer } from '../lib/peer.js' |
| 16 | 16 | import { KNOCKS } from '../lib/feed.js' |
| 17 | import { attest } from './helpers.js' | |
| 17 | import { 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. | |
| 22 | const KNOCKER_ID = await identity() | |
| 23 | const KNOCKER = await KNOCKER_ID.device() | |
| 24 | const knockOp = (me, them) => Buffer.from(JSON.stringify({ t: 'knock', feed: them.key.toString('hex'), proof: KNOCKER.bind(me.feed.deviceKey) }), 'utf8') | |
| 18 | 25 | |
| 19 | 26 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-bell-')) |
| 20 | 27 | |
| … | … | @@ -35,7 +42,7 @@ test('a knock appears at the Peer surface and accepting it FOLLOWS, never admits |
| 35 | 42 | // block. So this exercises the doorbell's HANDLING — where a knock lands, what accepting it does — |
| 36 | 43 | // and not its authorisation. That a stranger may knock and may do nothing else is proven at the |
| 37 | 44 | // 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)) | |
| 39 | 46 | await me.update() |
| 40 | 47 | |
| 41 | 48 | const knocks = await me.knocks() |
| … | … | @@ -56,7 +63,7 @@ test('dismissing clears without following', async (t) => { |
| 56 | 63 | const them = await mkFeed() |
| 57 | 64 | t.teardown(() => Promise.all([me.close(), them.close()])) |
| 58 | 65 | |
| 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)) | |
| 60 | 67 | await me.update() |
| 61 | 68 | await me.dismissKnock(them.key) |
| 62 | 69 | await me.update() |
| … | … | @@ -70,7 +77,7 @@ test('knocks never leak into the timeline or the record set', async (t) => { |
| 70 | 77 | const them = await mkFeed() |
| 71 | 78 | t.teardown(() => Promise.all([me.close(), them.close()])) |
| 72 | 79 | |
| 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)) | |
| 74 | 81 | await me.update() |
| 75 | 82 | |
| 76 | 83 | t.alike(await me.timeline(), [], 'no posts') |
| … | … | @@ -25,7 +25,12 @@ import { join } from '#node:path' |
| 25 | 25 | import Corestore from 'corestore' |
| 26 | 26 | import { RecordStore } from '../lib/record-store.js' |
| 27 | 27 | import { Feed } from '../lib/feed-base.js' |
| 28 | import { attest } from './helpers.js' | |
| 28 | import { 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. | |
| 32 | const STRANGER = await (await identity()).device() | |
| 33 | const asStranger = (writerKeyHex) => STRANGER.bind(writerKeyHex) | |
| 29 | 34 | |
| 30 | 35 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-bell-wire-')) |
| 31 | 36 | const hex = (k) => b4a.toString(k, 'hex') |
| … | … | @@ -75,7 +80,7 @@ test('★★★ a stranger who is NOT a writer rings the doorbell from another m |
| 75 | 80 | await until(async () => { await aliceOnBob.update(); return (await aliceOnBob.devices()).length > 0 }) |
| 76 | 81 | t.absent(aliceOnBob.writable, '⛔ he is NOT a writer on her feed — that is the whole premise') |
| 77 | 82 | |
| 78 | await aliceOnBob.knock(bob.key) | |
| 83 | await aliceOnBob.knock(bob.key, asStranger) | |
| 79 | 84 | |
| 80 | 85 | const knocks = await until(async () => { |
| 81 | 86 | await alice.update() |
| … | … | @@ -98,7 +103,7 @@ test('⛔ and it grants nothing — a knock is not admission', async (t) => { |
| 98 | 103 | t.teardown(async () => { unpipe(); await alice.close(); await bob.close(); await aliceOnBob.close() }) |
| 99 | 104 | |
| 100 | 105 | 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) | |
| 102 | 107 | await until(async () => { await alice.update(); return (await alice.knocks()).length }) |
| 103 | 108 | |
| 104 | 109 | t.alike(await alice.devices(), before, "her writer set is untouched — the thing a doorbell must never change") |
| … | … | @@ -23,7 +23,12 @@ import Corestore from 'corestore' |
| 23 | 23 | import { RecordStore } from '../lib/record-store.js' |
| 24 | 24 | import { Feed } from '../lib/feed-base.js' |
| 25 | 25 | import { POST } from '../lib/peer.js' |
| 26 | import { attest } from './helpers.js' | |
| 26 | import { 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. | |
| 30 | const STRANGER = await (await identity()).device() | |
| 31 | const asStranger = (writerKeyHex) => STRANGER.bind(writerKeyHex) | |
| 27 | 32 | |
| 28 | 33 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-holder-knock-')) |
| 29 | 34 | |
| … | … | @@ -69,7 +74,7 @@ async function knockedFeed (t) { |
| 69 | 74 | const unpipe = pipe(alice, aliceOnBob) |
| 70 | 75 | |
| 71 | 76 | 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) | |
| 73 | 78 | const knocked = await until(async () => { await alice.update(); return (await alice.knocks()).length }) |
| 74 | 79 | t.ok(knocked, 'setup: the stranger\'s knock landed in the owner\'s feed') |
| 75 | 80 |
| … | … | @@ -33,7 +33,9 @@ async function mkPeer () { |
| 33 | 33 | await f.ready() |
| 34 | 34 | await f.addDevice(d.bind(f.deviceKey)) |
| 35 | 35 | 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 | |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | 41 | // 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) |
| 94 | 96 | const { alice, bob, target } = await pair() |
| 95 | 97 | await bob.like(target) |
| 96 | 98 | 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 | |
| 98 | 100 | await alice.update() |
| 99 | 101 | t.is((await alice.feed.knocks()).length, 0, 'she dismissed it') |
| 100 | 102 |
| … | … | @@ -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 | ||
| 16 | import test from 'brittle' | |
| 17 | import { identity } from './helpers.js' | |
| 18 | import { applyOp, isRosterKey, KNOCKS, LISTING, LISTING_ROOT } from '../lib/feed.js' | |
| 19 | import { proofsFor } from './helpers.js' | |
| 20 | ||
| 21 | const 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 | ||
| 31 | const OWNER = 'd1'.repeat(32) // the registry's own device | |
| 32 | const STRANGER = 'ff'.repeat(32) // the knocking WRITER core, on the registry's log | |
| 33 | const OTHER_WRITER = 'ee'.repeat(32) | |
| 34 | const THEIR_FEED = 'ab'.repeat(32) | |
| 35 | const VICTIM_FEED = 'cd'.repeat(32) | |
| 36 | ||
| 37 | const PROOF = await proofsFor([OWNER]) | |
| 38 | const STRANGER_ID = await identity() | |
| 39 | const STRANGER_DEVICE = await STRANGER_ID.device() | |
| 40 | const STRANGER_ROOT = STRANGER_ID.rootHex | |
| 41 | ||
| 42 | const addDevice = (device) => ({ t: 'add-device', device, proof: PROOF[device] }) | |
| 43 | const 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 | } | |
| 50 | const knocksIn = (v) => v.entries().filter(([k]) => k.startsWith(KNOCKS)) | |
| 51 | ||
| 52 | test('⛔ 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 | ||
| 57 | test('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 | ||
| 66 | test('⛔ 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 | ||
| 74 | test('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 | ||
| 83 | test('⛔ 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 | ||
| 96 | test('⛔ 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 | ||
| 106 | test('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 | }) |
| … | … | @@ -42,16 +42,20 @@ const run = async (ops, view = kv()) => { |
| 42 | 42 | const PROOF = await proofsFor([D1, THEIR_FEED]) |
| 43 | 43 | // ⛔ A SEPARATE identity — a stranger is someone else's device, not a co-owner. Minting it |
| 44 | 44 | // from the same root would make every "a stranger cannot" test pass for the wrong reason. |
| 45 | const OUTSIDER = await (await identity()).device() | |
| 45 | const OUTSIDER_ID = await identity() | |
| 46 | const OUTSIDER = await OUTSIDER_ID.device() | |
| 47 | const OUTSIDER_ROOT = OUTSIDER_ID.rootHex | |
| 46 | 48 | const STRANGER_PROOF = OUTSIDER.bind(STRANGER) |
| 47 | 49 | const addDevice = (device) => ({ t: 'add-device', device, proof: device === STRANGER ? STRANGER_PROOF : PROOF[device] }) |
| 48 | const 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. | |
| 52 | const knock = (feed, writer = STRANGER) => ({ t: 'knock', feed, proof: writer === STRANGER ? STRANGER_PROOF : PROOF[writer] }) | |
| 49 | 53 | const put = (collection, name) => ({ t: 'put', collection, name, $oid: 'o', size: 1 }) |
| 50 | 54 | |
| 51 | 55 | test('a stranger may knock, and it is recorded', async (t) => { |
| 52 | 56 | 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') | |
| 55 | 59 | }) |
| 56 | 60 | |
| 57 | 61 | test('⛔ 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) => |
| 63 | 67 | ]) |
| 64 | 68 | t.is(await v.get('post/x'), null, 'a knocker still cannot write a record') |
| 65 | 69 | 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') | |
| 67 | 71 | }) |
| 68 | 72 | |
| 69 | 73 | test('⛔ a knock cannot forge another feed\'s entry or reach outside the namespace', async (t) => { |
| 70 | 74 | const v = await run([ |
| 71 | 75 | [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 }] | |
| 75 | 80 | ]) |
| 76 | 81 | t.alike(v.entries().filter(([k]) => k.startsWith(KNOCKS)), [], 'malformed knocks write nothing') |
| 77 | 82 | }) |
| … | … | @@ -79,12 +84,12 @@ test('⛔ a knock cannot forge another feed\'s entry or reach outside the namesp |
| 79 | 84 | test('knocking twice is one knock', async (t) => { |
| 80 | 85 | const v = await run([[D1, addDevice(D1)], [STRANGER, knock(THEIR_FEED)], [STRANGER, knock(THEIR_FEED)]]) |
| 81 | 86 | 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') | |
| 83 | 88 | }) |
| 84 | 89 | |
| 85 | 90 | test('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 }) | |
| 88 | 93 | }) |
| 89 | 94 | |
| 90 | 95 | test('⛔ 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) => { |
| 107 | 112 | const v = await run([ |
| 108 | 113 | [D1, addDevice(D1)], |
| 109 | 114 | [STRANGER, knock(THEIR_FEED)], |
| 110 | [STRANGER, { t: 'dismiss-knock', feed: THEIR_FEED }] | |
| 115 | [STRANGER, { t: 'dismiss-knock', root: OUTSIDER_ROOT }] | |
| 111 | 116 | ]) |
| 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 }, | |
| 113 | 118 | 'a stranger cannot clear its own knock — nor anyone else\'s') |
| 114 | 119 | |
| 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') | |
| 117 | 122 | }) |
| … | … | @@ -31,7 +31,9 @@ async function mkPeer () { |
| 31 | 31 | await f.ready() |
| 32 | 32 | await f.addDevice(d.bind(f.deviceKey)) |
| 33 | 33 | 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 } | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | // 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 |
| 109 | 111 | // distinction is load-bearing: a knock from a held feed is a SECOND notification for an act that |
| 110 | 112 | // already produced one, so it is skipped, and this test would have been the thing that noticed. |
| 111 | 113 | 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)) | |
| 113 | 118 | await me.peer.update() |
| 114 | 119 | |
| 115 | 120 | const knocks = (await me.peer.notifications()).filter((n) => n.kind === 'knock') |
| … | … | @@ -8,7 +8,7 @@ import Corestore from 'corestore' |
| 8 | 8 | import { RecordStore } from '../lib/record-store.js' |
| 9 | 9 | import { Feed } from '../lib/feed-base.js' |
| 10 | 10 | import { Peer } from '../lib/peer.js' |
| 11 | import { attest } from './helpers.js' | |
| 11 | import { attest, identity } from './helpers.js' | |
| 12 | 12 | |
| 13 | 13 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-reg-')) |
| 14 | 14 | const rec = (body) => ({ frontmatter: { createdAt: '2026-09-06T00:00:00Z' }, body }) |
| … | … | @@ -22,14 +22,26 @@ async function mkFeed () { |
| 22 | 22 | } |
| 23 | 23 | const mkPeer = async () => new Peer(await mkFeed()).ready() |
| 24 | 24 | |
| 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. | |
| 27 | async 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 | ||
| 25 | 37 | test('ask, honour, browse — and the knock is cleared by being honoured', async (t) => { |
| 26 | 38 | const registry = await mkPeer() |
| 27 | const them = await mkFeed() | |
| 39 | const them = await mkOwned() | |
| 28 | 40 | t.teardown(() => Promise.all([registry.close(), them.close()])) |
| 29 | 41 | |
| 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) | |
| 33 | 45 | await registry.update() |
| 34 | 46 | t.is((await registry.knocks()).length, 1, 'the request is waiting') |
| 35 | 47 | |
| … | … | @@ -48,7 +60,10 @@ test('⛔ a registry cannot list a feed that never asked', async (t) => { |
| 48 | 60 | const them = await mkFeed() |
| 49 | 61 | t.teardown(() => Promise.all([registry.close(), them.close()])) |
| 50 | 62 | |
| 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') | |
| 52 | 67 | await registry.update() |
| 53 | 68 | |
| 54 | 69 | 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) => { |
| 57 | 72 | test('subscribing to a registry surfaces its listings, without being in it', async (t) => { |
| 58 | 73 | const me = await mkPeer() |
| 59 | 74 | const registry = await mkPeer() |
| 60 | const them = await mkFeed() | |
| 75 | const them = await mkOwned() | |
| 61 | 76 | t.teardown(() => Promise.all([me.close(), registry.close(), them.close()])) |
| 62 | 77 | |
| 63 | await registry.feed.knock(them.key) | |
| 78 | await registry.feed.knock(them.key, them.asOwner) | |
| 64 | 79 | await registry.update() |
| 65 | 80 | await registry.listFeed(them.key, rec('listed')) |
| 66 | 81 | await registry.update() |
| … | … | @@ -77,10 +92,10 @@ test('subscribing to a registry surfaces its listings, without being in it', asy |
| 77 | 92 | |
| 78 | 93 | test('delisting removes it from the directory but is not a retraction', async (t) => { |
| 79 | 94 | const registry = await mkPeer() |
| 80 | const them = await mkFeed() | |
| 95 | const them = await mkOwned() | |
| 81 | 96 | t.teardown(() => Promise.all([registry.close(), them.close()])) |
| 82 | 97 | |
| 83 | await registry.feed.knock(them.key) | |
| 98 | await registry.feed.knock(them.key, them.asOwner) | |
| 84 | 99 | await registry.update() |
| 85 | 100 | await registry.listFeed(them.key, rec('listed')) |
| 86 | 101 | await registry.update() |
| … | … | @@ -96,11 +111,11 @@ test('the same feed listed by two registries is two endorsements, not one', asyn |
| 96 | 111 | const me = await mkPeer() |
| 97 | 112 | const r1 = await mkPeer() |
| 98 | 113 | const r2 = await mkPeer() |
| 99 | const them = await mkFeed() | |
| 114 | const them = await mkOwned() | |
| 100 | 115 | t.teardown(() => Promise.all([me.close(), r1.close(), r2.close(), them.close()])) |
| 101 | 116 | |
| 102 | 117 | for (const r of [r1, r2]) { |
| 103 | await r.feed.knock(them.key) | |
| 118 | await r.feed.knock(them.key, them.asOwner) | |
| 104 | 119 | await r.update() |
| 105 | 120 | await r.listFeed(them.key, rec('listed')) |
| 106 | 121 | await r.update() |
| … | … | @@ -113,3 +128,42 @@ test('the same feed listed by two registries is two endorsements, not one', asyn |
| 113 | 128 | t.is(dir.length, 2, 'both kept — collapsing them would throw away who vouched') |
| 114 | 129 | t.alike(new Set(dir.map((x) => x.feed)).size, 1, 'for one feed') |
| 115 | 130 | }) |
| 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. | |
| 135 | test('⛔ 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 | }) |
| … | … | @@ -1,9 +1,10 @@ |
| 1 | 1 | // The registry — a feed whose records are listings of other feeds. No new primitive. |
| 2 | 2 | // |
| 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). | |
| 7 | 8 | // |
| 8 | 9 | // Two honesty limits, both from hyper-social #61 §8 and neither fixable here: |
| 9 | 10 | // Delisting stops propagation; it does not retract. The listing already replicated. |
| … | … | @@ -12,7 +13,7 @@ |
| 12 | 13 | |
| 13 | 14 | import test from 'brittle' |
| 14 | 15 | import { proofsFor } from './helpers.js' |
| 15 | import { applyOp, isRosterKey, KNOCKS } from '../lib/feed.js' | |
| 16 | import { applyOp, isRosterKey, KNOCKS, LISTING_ROOT } from '../lib/feed.js' | |
| 16 | 17 | |
| 17 | 18 | const kv = () => { |
| 18 | 19 | const m = new Map() |
| … | … | @@ -40,14 +41,17 @@ const run = async (ops, view = kv()) => { |
| 40 | 41 | // One owner's devices. Minted once so every call site stays synchronous. |
| 41 | 42 | const PROOF = await proofsFor([OWNER, OTHER, FEED_A, FEED_B]) |
| 42 | 43 | const addDevice = (d) => ({ t: 'add-device', device: d, proof: PROOF[d] }) |
| 43 | const knock = (feed) => ({ t: 'knock', feed }) | |
| 44 | const 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. | |
| 45 | const ROOT = PROOF.__root | |
| 46 | const knock = (feed) => ({ t: 'knock', feed, proof: PROOF[OTHER] }) | |
| 47 | const listFeed = (feed) => ({ t: 'list-feed', root: ROOT, feed, $oid: 'oid-' + feed, size: 10 }) | |
| 45 | 48 | const delist = (feed) => ({ t: 'delist', feed }) |
| 46 | 49 | |
| 47 | 50 | test('a knock then a listing — two parties, two acts', async (t) => { |
| 48 | 51 | const v = await run([[OWNER, addDevice(OWNER)], [OTHER, knock(FEED_A)], [OWNER, listFeed(FEED_A)]]) |
| 49 | 52 | t.alike(await v.get('listing/' + FEED_A), { $oid: 'oid-' + FEED_A, size: 10 }, |
| 50 | 53 | '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') | |
| 51 | 55 | }) |
| 52 | 56 | |
| 53 | 57 | test('⛔ 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 |
| 58 | 62 | test('⛔ a knock alone does not list you', async (t) => { |
| 59 | 63 | const v = await run([[OWNER, addDevice(OWNER)], [OTHER, knock(FEED_A)]]) |
| 60 | 64 | 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') | |
| 62 | 66 | }) |
| 63 | 67 | |
| 64 | 68 | test('⛔ 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) => { |
| 70 | 74 | const v = await run([ |
| 71 | 75 | [OWNER, addDevice(OWNER)], |
| 72 | 76 | [OTHER, knock(FEED_A)], |
| 73 | [OWNER, { t: 'dismiss-knock', feed: FEED_A }], | |
| 77 | [OWNER, { t: 'dismiss-knock', root: ROOT }], | |
| 74 | 78 | [OWNER, listFeed(FEED_A)] |
| 75 | 79 | ]) |
| 76 | 80 | 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) => { |
| 110 | 114 | |
| 111 | 115 | test('a malformed listing writes nothing and does not throw', async (t) => { |
| 112 | 116 | 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 }]]) | |
| 116 | 121 | t.is(await v.get('listing/' + FEED_A), null, 'a listing without a pointer is not a listing') |
| 117 | 122 | }) |