| … | … | @@ -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 | ||
| 27 | import 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. | |
| 32 | const ALPHABET = '0123456789abcdefghijklmnopqrstuv' | |
| 33 | const TIME_LEN = 10 // 32^10 ms ≈ year 38,000. Fixed width, because sorting needs it. | |
| 34 | const RAND_LEN = 16 // 80 bits | |
| 35 | ||
| 36 | export const NAME_LEN = TIME_LEN + RAND_LEN | |
| 37 | const NAME_RE = new RegExp('^[' + ALPHABET + ']{' + NAME_LEN + '}$') | |
| 38 | ||
| 39 | function 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 | */ | |
| 56 | export 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. */ | |
| 64 | export 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 | */ | |
| 76 | export 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 | ||
| 87 | export default { recordName, isRecordName, nameTime, NAME_LEN } |
| … | … | @@ -31,6 +31,7 @@ |
| 31 | 31 | import Autobee from 'autobee' |
| 32 | 32 | import b4a from 'b4a' |
| 33 | 33 | import { readAttested, signAttested } from './attestation.js' |
| 34 | import { recordName } from './name.js' | |
| 34 | 35 | |
| 35 | 36 | const SEP = '/' |
| 36 | 37 | const MEMBER = '__members__' |
| … | … | @@ -326,6 +327,17 @@ async function applyOps (batch, view, host, roomHex) { |
| 326 | 327 | // then membership would describe nothing and "visibility is a place" would be a promise |
| 327 | 328 | // rather than a property. |
| 328 | 329 | 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 | |
| 329 | 341 | await b.set(msgKey(op.name), { |
| 330 | 342 | author: signer.root, body: String(op.body || ''), at: typeof op.at === 'number' ? op.at : 0 |
| 331 | 343 | }) |
| … | … | @@ -446,7 +458,13 @@ export class Room { |
| 446 | 458 | await this.base.append(enc(this._op('config', { key, value }, cred)), { optimistic: true }) |
| 447 | 459 | } |
| 448 | 460 | |
| 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()) { | |
| 450 | 468 | await this.base.append(enc(this._op('message', { name, body: String(body || '') }, cred)), { optimistic: true }) |
| 451 | 469 | return name |
| 452 | 470 | } |
| … | … | @@ -13,6 +13,7 @@ import fs from '#node:fs' |
| 13 | 13 | import { Peer, LIKE_VALUE } from './peer.js' |
| 14 | 14 | import { linkMentions } from './mentions.js' |
| 15 | 15 | import { graphOf } from './graph.js' |
| 16 | import { recordName } from './name.js' | |
| 16 | 17 | |
| 17 | 18 | export function createServer (node, sockPath, { onError = () => {} } = {}) { |
| 18 | 19 | // 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) { |
| 138 | 139 | } |
| 139 | 140 | case 'unlink': { await peer.feed.removeDevice(a.device); await peer.update(); return { revoked: a.device } } |
| 140 | 141 | 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() | |
| 142 | 145 | await peer.feed.put('post', name, { |
| 143 | 146 | frontmatter: { createdAt: new Date().toISOString() }, |
| 144 | 147 | body: await mentioned(node, a.body) |
| … | … | @@ -206,7 +209,9 @@ export async function dispatch (node, cmd, a) { |
| 206 | 209 | // Have I liked it? Asked by a page that renders ONE post and has no timeline to stamp. |
| 207 | 210 | case 'liked': return { liked: await peer.likedByMe(a.target) } |
| 208 | 211 | 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() | |
| 210 | 215 | await peer.reply(a.target, await mentioned(node, a.body), name) |
| 211 | 216 | await peer.update() |
| 212 | 217 | return { name } |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.6", | |
| 3 | "version": "0.0.38", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "favela-peer", |
| 9 | "version": "0.0.6", | |
| 9 | "version": "0.0.38", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "anproto": "file:../anproto", |
| 12 | 12 | "autobee": "^2.1.0", |
| … | … | @@ -20,9 +20,11 @@ |
| 20 | 20 | "blind-pairing": "^2.3.1", |
| 21 | 21 | "compact-encoding": "^3.3.0", |
| 22 | 22 | "corestore": "^7.12.3", |
| 23 | "favela-identity": "file:../favela-identity", | |
| 23 | 24 | "favela-markdown": "file:../favela-markdown", |
| 24 | 25 | "hyper-identity2": "file:../packages/hyper-identity2", |
| 25 | 26 | "hyper-overlay": "file:../hyper-overlay", |
| 27 | "hypercore-crypto": "^3.7.0", | |
| 26 | 28 | "hyperswarm": "^4.17.0", |
| 27 | 29 | "keet-identity-key": "3.2.0", |
| 28 | 30 | "protomux": "^3.11.0", |
| … | … | @@ -47,8 +49,24 @@ |
| 47 | 49 | "brittle": "^3.0.0" |
| 48 | 50 | } |
| 49 | 51 | }, |
| 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 | }, | |
| 50 | 68 | "../favela-markdown": { |
| 51 | "version": "0.1.0", | |
| 69 | "version": "0.1.5", | |
| 52 | 70 | "license": "MIT", |
| 53 | 71 | "dependencies": { |
| 54 | 72 | "bare-fs": "^4.8.1", |
| … | … | @@ -1191,6 +1209,10 @@ |
| 1191 | 1209 | "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", |
| 1192 | 1210 | "license": "MIT" |
| 1193 | 1211 | }, |
| 1212 | "node_modules/favela-identity": { | |
| 1213 | "resolved": "../favela-identity", | |
| 1214 | "link": true | |
| 1215 | }, | |
| 1194 | 1216 | "node_modules/favela-markdown": { |
| 1195 | 1217 | "resolved": "../favela-markdown", |
| 1196 | 1218 | "link": true |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "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.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | 7 | "test": "brittle-bare test/*.test.js" |
| … | … | @@ -29,6 +29,7 @@ |
| 29 | 29 | } |
| 30 | 30 | }, |
| 31 | 31 | "dependencies": { |
| 32 | "anproto": "file:../anproto", | |
| 32 | 33 | "autobee": "^2.1.0", |
| 33 | 34 | "b4a": "^1.6.0", |
| 34 | 35 | "bare-fs": "^4.8.1", |
| … | … | @@ -36,19 +37,19 @@ |
| 36 | 37 | "bare-os": "^3.9.3", |
| 37 | 38 | "bare-path": "^3.1.2", |
| 38 | 39 | "bare-process": "^4.5.1", |
| 40 | "bip39-mnemonic": "^2.2.0", | |
| 41 | "blind-pairing": "^2.3.1", | |
| 42 | "compact-encoding": "^3.3.0", | |
| 39 | 43 | "corestore": "^7.12.3", |
| 44 | "favela-identity": "file:../favela-identity", | |
| 40 | 45 | "favela-markdown": "file:../favela-markdown", |
| 46 | "hyper-identity2": "file:../packages/hyper-identity2", | |
| 41 | 47 | "hyper-overlay": "file:../hyper-overlay", |
| 48 | "hypercore-crypto": "^3.7.0", | |
| 42 | 49 | "hyperswarm": "^4.17.0", |
| 43 | "protomux": "^3.11.0", | |
| 44 | 50 | "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" | |
| 52 | 53 | }, |
| 53 | 54 | "devDependencies": { |
| 54 | 55 | "brittle": "^4.0.2" |
| … | … | @@ -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 | ||
| 19 | import test from 'brittle' | |
| 20 | import { recordName, nameTime, isRecordName, NAME_LEN } from '../lib/name.js' | |
| 21 | ||
| 22 | test('★★ 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. | |
| 30 | test('⛔ 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 | ||
| 35 | test('★★ 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 | ||
| 48 | test('★ 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 | ||
| 56 | test('★ 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 | ||
| 61 | test('★ 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. | |
| 75 | test('⛔ 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 | }) |
| … | … | @@ -418,3 +418,49 @@ test('⛔⛔ a payload can never overwrite the envelope', async (t) => { |
| 418 | 418 | t.is(op.room, r.key.toString('hex'), 'this room, whatever the payload said') |
| 419 | 419 | t.is(op.root, a.root, 'and the real signer') |
| 420 | 420 | }) |
| 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. | |
| 430 | test('⛔ 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 | ||
| 453 | test('★ 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 | }) |