OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / eb3a41d

0.0.38: a record's name stops being a clock reading
Three naming schemes, none of them safe. rpc.js minted `a.name || String(Date.now())` for both post and reply; room.js used a clock plus four hex characters. hyper-social #59 §2 named both faults — a millisecond clock COLLIDES, and accepting `a.name` made a collision CHOOSABLE rather than merely improbable. Nothing in the daemon ever passed a name, so that input was pure attack surface and is gone. lib/name.js mints one instead: 10 base32hex characters of milliseconds and 16 of randomness, fixed width, lowercase, one path segment. base32hex because ASCII order matches value order, so a plain string sort is a chronological sort — which the timeline, the record store's byte-ordered scan and the bsky TID projection all lean on. 10,000 names minted in a single millisecond are now 10,000 distinct names. ⛔ A NAME IS NOT A POSITION. #59 §2 proposed assigning it inside apply the way keet assigns index: p.count. #61 §2b measured keet and found the reverse, and so did I reading contract/chat.js: {thread, index} is a view COORDINATE, and parent, replyTo and the edit target all resolve through @keet/chat-by-message-id keyed by oplog (:37-43, :89-101). A position is a function of the linearisation, the linearisation is re-derived on every boot and every reorg, so a permalink built from one can change after it has been handed out. This reproduces oplog's properties — settled at write time, stable under reorg — without the coordination. ⛔ AND THE SECURITY FIX, which is the half with teeth. room.js applied a message with an unconditional set, and the row carries `author` — so posting under a name another member had already used replaced their words AND took their byline, silently, with nothing in the view to show it. Not a race: an op is {t:'message', name, body}, so any writer on the room's autobee can pick the name directly whatever the RPC surface allows. A generator cannot fix that, because apply must never trust the op. Apply now refuses a name already held by a different author; the same author may still revise, which is the only edit path a room has. Test written first and red for the right reason: B's message replaced A's and took the byline. ⚠ NO MIGRATION. record-store.js treats a name as an opaque path segment, so legacy clock names coexist with new ones and records live on three machines keep working. nameTime() returns null for a legacy name rather than parsing it into a number that would mean something different. A test pins this so nobody later "tidies up" by rejecting them. hypercore-crypto was already present transitively; declared directly now that it is imported. favela-peer 322/322 tests, 777/777 asserts (was 313/750) ⚠ Still open: the AUTHORITY half of favela://<...>/<collection>/<name>, which is hyper-social #61 §12 #1. This settles the last segment only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eb3a41d3e7a620b8bde839377b25823524baad0b
by plankatron <fleeky@gmail.com> · 2026-09-08T14:24:44.000Z

Changed files (7) +276 -16

lib/name.js +87 -0
……@@ -0,0 +1,87 @@
1// name.js — what a record is called.
2//
3// ⛔ THE DEFECT. A record's name was `String(Date.now())` (rpc.js) or a clock plus four hex
4// characters (room.js). hyper-social #59 §2 named both faults: a millisecond clock COLLIDES, and the
5// name came in as `a.name || …` — caller-supplied — so a collision was CHOOSABLE rather than merely
6// improbable. Apply wrote it with an unconditional `set`, so a collision silently replaced a record,
7// and in a room it silently reattributed one to whoever collided.
8//
9// ⛔ AND A NAME IS NOT A POSITION. #59 §2 proposed assigning it inside apply, the way keet assigns
10// `index: p.count`. #61 §2b measured keet and found the reverse: `{thread, index}` is a view
11// COORDINATE, and every cross-reference — parent, replyTo, and the edit target — resolves through
12// `@keet/chat-by-message-id`, keyed by `oplog` (contract/chat.js:37-43, :89-101). A position is a
13// function of the linearisation; the linearisation is re-derived on every boot and every reorg. So a
14// permalink built from a position can change AFTER it has been handed out and embedded in bytes that
15// #8 forbids rewriting. `oplog` is collision-free by construction, known at append time and stable
16// under reorg — that is the property being reproduced here, without the coordination.
17//
18// So a name is: minted at write time (bytes go to a path before any append lands), carrying enough
19// entropy that a collision is not a practical event, ordered by time because timelines and every
20// bridge's rkey depend on it, and never supplied by a caller.
21//
22// ⚠ WHAT THIS IS NOT. This does not make a name an identity in keet's sense — provenance still lives
23// in the log, and two records with the same bytes in two rooms are still two disclosures
24// (SOCIAL-MODEL §9 #5). It removes the collision and the caller's ability to choose one. The
25// address question — what the authority segment of `favela://` is — is #61 §12 #1 and stays open.
26
27import crypto from 'hypercore-crypto'
28
29// base32hex, lowercase (RFC 4648 §7 alphabet, downcased). Chosen because ASCII order matches value
30// order — '0' < '9' < 'a' < 'v' — so a plain string sort is a chronological sort. That is a property
31// the timeline, the record store's byte-ordered scan, and the bsky TID projection all lean on.
32const ALPHABET = '0123456789abcdefghijklmnopqrstuv'
33const TIME_LEN = 10 // 32^10 ms ≈ year 38,000. Fixed width, because sorting needs it.
34const RAND_LEN = 16 // 80 bits
35
36export const NAME_LEN = TIME_LEN + RAND_LEN
37const NAME_RE = new RegExp('^[' + ALPHABET + ']{' + NAME_LEN + '}$')
38
39function encode (value, width) {
40 let n = BigInt(value)
41 let out = ''
42 for (let i = 0; i < width; i++) {
43 out = ALPHABET[Number(n % 32n)] + out
44 n /= 32n
45 }
46 return out
47}
48
49/**
50 * Mint a name for a new record.
51 *
52 * ⛔ `at` and `bytes` are injectable FOR TESTS ONLY. Nothing in the daemon passes them, and no RPC
53 * surface may ever expose them — a caller who can choose either can choose a collision, which is
54 * precisely the defect this file exists to remove.
55 */
56export function recordName (at = Date.now(), bytes = null) {
57 const r = bytes || crypto.randomBytes(10) // 80 bits
58 let n = 0n
59 for (let i = 0; i < 10; i++) n = (n << 8n) | BigInt(r[i])
60 return encode(Math.floor(at), TIME_LEN) + encode(n, RAND_LEN)
61}
62
63/** True if `s` is one of ours. Deliberately strict: a legacy clock name is NOT this format. */
64export function isRecordName (s) {
65 return typeof s === 'string' && NAME_RE.test(s)
66}
67
68/**
69 * The moment a name was minted, or null.
70 *
71 * ⛔ Returns null for a legacy clock name rather than parsing it. Records written before this change
72 * are live on three machines and `record-store.js` treats a name as an opaque path segment, so the
73 * two formats coexist and nothing needs migrating — but a caller must be told which it is holding
74 * rather than handed a number that means something different.
75 */
76export function nameTime (s) {
77 if (!isRecordName(s)) return null
78 let n = 0n
79 for (const ch of s.slice(0, TIME_LEN)) {
80 const v = ALPHABET.indexOf(ch)
81 if (v < 0) return null
82 n = n * 32n + BigInt(v)
83 }
84 return Number(n)
85}
86
87export default { recordName, isRecordName, nameTime, NAME_LEN }
lib/room.js +19 -1
……@@ -31,6 +31,7 @@
3131import Autobee from 'autobee'
3232import b4a from 'b4a'
3333import { readAttested, signAttested } from './attestation.js'
34import { recordName } from './name.js'
3435
3536const SEP = '/'
3637const MEMBER = '__members__'
……@@ -326,6 +327,17 @@ async function applyOps (batch, view, host, roomHex) {
326327 // then membership would describe nothing and "visibility is a place" would be a promise
327328 // rather than a property.
328329 if (!(await at(b, signer, PEER))) continue
330 // ⛔ A NAME ALREADY HELD BY SOMEBODY ELSE IS REFUSED. This was an unconditional `set`, and the
331 // row carries `author`, so posting under a name another member had used replaced their words
332 // AND took their byline — silently, with nothing in the view to show it. Not a race: an op is
333 // `{t:'message', name, body}`, so any writer on this autobee can pick the name directly no
334 // matter what the RPC surface allows. lib/name.js makes an accidental collision impractical;
335 // only apply can make a deliberate one ineffective, because apply must never trust the op.
336 //
337 // Same author is still allowed through: that is a revision of your own record, and it is the
338 // only edit path a room has today.
339 const prior = await b.get(msgKey(op.name))
340 if (prior && prior.author && prior.author !== signer.root) continue
329341 await b.set(msgKey(op.name), {
330342 author: signer.root, body: String(op.body || ''), at: typeof op.at === 'number' ? op.at : 0
331343 })
……@@ -446,7 +458,13 @@ export class Room {
446458 await this.base.append(enc(this._op('config', { key, value }, cred)), { optimistic: true })
447459 }
448460
449 async post (body, cred, name = String(Date.now()) + Math.random().toString(16).slice(2, 6)) {
461 // ⛔ The default was `String(Date.now()) + 4 hex chars` — a THIRD naming scheme, different again
462 // from the two in rpc.js, and one whose digit-subsequence collapsed 83% of names when
463 // favela-bridge derived a bsky rkey from it. One generator now, in lib/name.js.
464 //
465 // `name` stays a parameter only so a test can force a collision and prove apply refuses it. No
466 // caller in the daemon passes it.
467 async post (body, cred, name = recordName()) {
450468 await this.base.append(enc(this._op('message', { name, body: String(body || '') }, cred)), { optimistic: true })
451469 return name
452470 }
lib/rpc.js +7 -2
……@@ -13,6 +13,7 @@ import fs from '#node:fs'
1313import { Peer, LIKE_VALUE } from './peer.js'
1414import { linkMentions } from './mentions.js'
1515import { graphOf } from './graph.js'
16import { recordName } from './name.js'
1617
1718export function createServer (node, sockPath, { onError = () => {} } = {}) {
1819 // A stale socket from a crashed daemon would make bind fail with EADDRINUSE and look like
……@@ -138,7 +139,9 @@ export async function dispatch (node, cmd, a) {
138139 }
139140 case 'unlink': { await peer.feed.removeDevice(a.device); await peer.update(); return { revoked: a.device } }
140141 case 'post': {
141 const name = a.name || String(Date.now())
142 // ⛔ MINTED HERE, NEVER TAKEN FROM THE CALLER. This was `a.name || String(Date.now())`:
143 // a millisecond clock collides, and accepting `a.name` made a collision CHOOSABLE. See lib/name.js.
144 const name = recordName()
142145 await peer.feed.put('post', name, {
143146 frontmatter: { createdAt: new Date().toISOString() },
144147 body: await mentioned(node, a.body)
……@@ -206,7 +209,9 @@ export async function dispatch (node, cmd, a) {
206209 // Have I liked it? Asked by a page that renders ONE post and has no timeline to stamp.
207210 case 'liked': return { liked: await peer.likedByMe(a.target) }
208211 case 'reply': {
209 const name = a.name || String(Date.now())
212 // ⛔ MINTED HERE, NEVER TAKEN FROM THE CALLER. This was `a.name || String(Date.now())`:
213 // a millisecond clock collides, and accepting `a.name` made a collision CHOOSABLE. See lib/name.js.
214 const name = recordName()
210215 await peer.reply(a.target, await mentioned(node, a.body), name)
211216 await peer.update()
212217 return { name }
package-lock.json +25 -3
……@@ -1,12 +1,12 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.6",
3 "version": "0.0.38",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "favela-peer",
9 "version": "0.0.6",
9 "version": "0.0.38",
1010 "dependencies": {
1111 "anproto": "file:../anproto",
1212 "autobee": "^2.1.0",
……@@ -20,9 +20,11 @@
2020 "blind-pairing": "^2.3.1",
2121 "compact-encoding": "^3.3.0",
2222 "corestore": "^7.12.3",
23 "favela-identity": "file:../favela-identity",
2324 "favela-markdown": "file:../favela-markdown",
2425 "hyper-identity2": "file:../packages/hyper-identity2",
2526 "hyper-overlay": "file:../hyper-overlay",
27 "hypercore-crypto": "^3.7.0",
2628 "hyperswarm": "^4.17.0",
2729 "keet-identity-key": "3.2.0",
2830 "protomux": "^3.11.0",
……@@ -47,8 +49,24 @@
4749 "brittle": "^3.0.0"
4850 }
4951 },
52 "../favela-identity": {
53 "version": "0.0.2",
54 "license": "MIT",
55 "dependencies": {
56 "b4a": "^1.6.0",
57 "bare-fs": "^4.0.0",
58 "bare-net": "^1.3.0",
59 "bare-path": "^3.0.0",
60 "compact-encoding": "^3.3.0",
61 "hypercore-crypto": "^3.4.0",
62 "keet-identity-key": "3.2.0"
63 },
64 "devDependencies": {
65 "brittle": "^3.5.0"
66 }
67 },
5068 "../favela-markdown": {
51 "version": "0.1.0",
69 "version": "0.1.5",
5270 "license": "MIT",
5371 "dependencies": {
5472 "bare-fs": "^4.8.1",
……@@ -1191,6 +1209,10 @@
11911209 "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
11921210 "license": "MIT"
11931211 },
1212 "node_modules/favela-identity": {
1213 "resolved": "../favela-identity",
1214 "link": true
1215 },
11941216 "node_modules/favela-markdown": {
11951217 "resolved": "../favela-markdown",
11961218 "link": true
package.json +11 -10
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.36",
4 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
3 "version": "0.0.38",
4 "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
77 "test": "brittle-bare test/*.test.js"
……@@ -29,6 +29,7 @@
2929 }
3030 },
3131 "dependencies": {
32 "anproto": "file:../anproto",
3233 "autobee": "^2.1.0",
3334 "b4a": "^1.6.0",
3435 "bare-fs": "^4.8.1",
……@@ -36,19 +37,19 @@
3637 "bare-os": "^3.9.3",
3738 "bare-path": "^3.1.2",
3839 "bare-process": "^4.5.1",
40 "bip39-mnemonic": "^2.2.0",
41 "blind-pairing": "^2.3.1",
42 "compact-encoding": "^3.3.0",
3943 "corestore": "^7.12.3",
44 "favela-identity": "file:../favela-identity",
4045 "favela-markdown": "file:../favela-markdown",
46 "hyper-identity2": "file:../packages/hyper-identity2",
4147 "hyper-overlay": "file:../hyper-overlay",
48 "hypercore-crypto": "^3.7.0",
4249 "hyperswarm": "^4.17.0",
43 "protomux": "^3.11.0",
4450 "keet-identity-key": "3.2.0",
45 "compact-encoding": "^3.3.0",
46 "hyper-identity2": "file:../packages/hyper-identity2",
47 "anproto": "file:../anproto",
48 "blind-pairing": "^2.3.1",
49 "sodium-hmac": "^2.0.1",
50 "bip39-mnemonic": "^2.2.0",
51 "favela-identity": "file:../favela-identity"
51 "protomux": "^3.11.0",
52 "sodium-hmac": "^2.0.1"
5253 },
5354 "devDependencies": {
5455 "brittle": "^4.0.2"
test/name.test.js +81 -0
……@@ -0,0 +1,81 @@
1// name.test.js — what a record is called.
2//
3// ⛔ THE DEFECT THIS CLOSES. A record's name was `String(Date.now())` (rpc.js post and reply) or a
4// clock plus four hex characters (room.js). Two things were wrong with that, and hyper-social #59 §2
5// named both: a millisecond clock COLLIDES, and the name was `a.name || …` — caller-supplied — so a
6// collision was CHOOSABLE, not merely improbable. Apply then wrote it with an unconditional `set`,
7// so a collision silently overwrote a record and, in a room, silently reattributed it.
8//
9// ⛔ AND IT IS NOT A POSITION. hyper-social #61 §2b measured keet and found the reverse of what #59
10// had recorded: `id: {thread, index}` is a view coordinate, and every cross-reference in keet
11// resolves through `@keet/chat-by-message-id` keyed by `oplog` (chat.js:37-43, and the EDIT target
12// at :89-101). A position is a function of the linearisation, and the linearisation is re-derived on
13// every boot and every reorg — so a permalink built from one can change after it has been handed
14// out. A name must be settled the moment the record exists and never move again.
15//
16// So: minted at write time, unguessable enough that collision is not a practical event, ordered by
17// time because timelines and every bridge's rkey want that, and NEVER supplied by a caller.
18
19import test from 'brittle'
20import { recordName, nameTime, isRecordName, NAME_LEN } from '../lib/name.js'
21
22test('★★ two names minted in the SAME millisecond differ', async (t) => {
23 const at = 1_788_000_000_000
24 const seen = new Set()
25 for (let i = 0; i < 10_000; i++) seen.add(recordName(at))
26 t.is(seen.size, 10_000, '10k names, one millisecond, zero collisions')
27})
28
29// The old shape, for contrast: this is what String(Date.now()) gave you.
30test('⛔ the OLD scheme collides on the first duplicate millisecond', async (t) => {
31 const at = 1_788_000_000_000
32 t.is(String(at), String(at), 'two posts in one millisecond were literally the same name')
33})
34
35test('★★ lexicographic order IS chronological order', async (t) => {
36 const early = recordName(1_788_000_000_000)
37 const mid = recordName(1_788_000_000_001)
38 const late = recordName(1_799_000_000_000)
39 t.ok(early < mid, 'one millisecond later sorts later')
40 t.ok(mid < late, 'and much later sorts later still')
41
42 // A whole run stays sorted — the property a timeline and a TID projection both depend on.
43 const names = []
44 for (let i = 0; i < 200; i++) names.push(recordName(1_788_000_000_000 + i))
45 t.alike(names, [...names].sort(), 'minting order and sort order agree')
46})
47
48test('★ fixed width, one path segment, no separator to escape', async (t) => {
49 const n = recordName()
50 t.is(n.length, NAME_LEN)
51 t.ok(/^[0-9a-v]+$/.test(n), 'base32hex, lowercase — sortable by ASCII and safe in a path or a URL')
52 t.absent(n.includes('/'))
53 t.absent(n.includes('.'))
54})
55
56test('★ a name carries the moment it was minted', async (t) => {
57 const at = 1_788_123_456_789
58 t.is(nameTime(recordName(at)), at, 'and decodes back exactly')
59})
60
61test('★ isRecordName accepts what we mint and rejects what we do not', async (t) => {
62 t.ok(isRecordName(recordName()))
63 t.absent(isRecordName('1788822724600'), 'a legacy clock name is NOT this format')
64 t.absent(isRecordName('self'))
65 t.absent(isRecordName(''))
66 t.absent(isRecordName(null))
67 t.absent(isRecordName(recordName() + 'x'), 'wrong length')
68 t.absent(isRecordName('W'.repeat(NAME_LEN)), 'outside the alphabet')
69})
70
71// ⛔ Legacy names must keep working. Records written before this change are on disk on three
72// machines, and `record-store.js` treats a name as an opaque path segment — so the two formats
73// coexist and nothing needs migrating. This test exists so nobody later "tidies up" by rejecting
74// them.
75test('⛔ legacy clock names remain valid record names — no migration', async (t) => {
76 t.is(nameTime('1788822724600'), null, 'we cannot decode one, and say so rather than guessing')
77 t.absent(isRecordName('1788822724600'), 'it is not the new format')
78 // What matters is that nothing here refuses it as a KEY; that is record-store.js's isSafeSeg,
79 // which asks only that it be a single non-dot path segment.
80 t.absent('1788822724600'.includes('/'), 'and it still satisfies that')
81})
test/room.test.js +46 -0
……@@ -418,3 +418,49 @@ test('⛔⛔ a payload can never overwrite the envelope', async (t) => {
418418 t.is(op.room, r.key.toString('hex'), 'this room, whatever the payload said')
419419 t.is(op.root, a.root, 'and the real signer')
420420})
421
422// ⛔ THE OVERWRITE. `apply` wrote a message with an unconditional `b.set(msgKey(op.name), …)` and
423// the row carries `author: signer.root` — so a member who posted under a name another member had
424// already used replaced their words AND took the byline, with no error and nothing in the log to
425// show it happened. Not a race: an op is `{t:'message', name, body}`, so any writer on the room's
426// autobee can choose the name directly, whatever the RPC surface does.
427//
428// The name generator (lib/name.js) makes an accidental collision impractical. It cannot make a
429// deliberate one impossible, because apply must never trust the op — so the refusal belongs here.
430test('⛔ one member cannot overwrite — or reattribute — another member\'s message', async (t) => {
431 const a = await who()
432 const b = await who()
433 const r = await founded(a.device)
434 t.teardown(() => r.close())
435
436 await r.addMember(b.root, PEER, a.device)
437 await r.update()
438 await r.admitDevice(b.device, a.device)
439 await r.update()
440
441 const name = await r.post('the original, by A', a.device)
442 await r.update()
443
444 await r.post('replaced, by B', b.device, name)
445 await r.update()
446
447 const msgs = await r.messages()
448 t.is(msgs.length, 1, 'still one message')
449 t.is(msgs[0].body, 'the original, by A', "A's words stand")
450 t.is(msgs[0].author, a.root, 'and the byline is still A')
451})
452
453test('★ a member may still overwrite their OWN message', async (t) => {
454 const a = await who()
455 const r = await founded(a.device)
456 t.teardown(() => r.close())
457
458 const name = await r.post('first', a.device)
459 await r.update()
460 await r.post('second', a.device, name)
461 await r.update()
462
463 const msgs = await r.messages()
464 t.is(msgs.length, 1)
465 t.is(msgs[0].body, 'second', 'the author can revise; only a stranger is refused')
466})