OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / firehose-time.test.js

104 lines · javascript ref:
// The firehose frame's `time` must be REAL, not derived from the rev.
//
// serve-membrane used to recompute `time` from the rev (revTime) so every device projected a
// byte-identical frame. That worked while revs were wall-clock TIDs. Revs are now a deterministic
// prev+1µs chain, so a derived time is pinned at the chain's ORIGIN and recedes from reality forever:
// on a chain whose head was minted 30 days ago, five more commits still derive a time 30 days stale.
// Relays read `time` as liveness metadata, and this fleet already fights non-archival relays dropping
// accounts that look stale — so a permanently-receding timestamp is a federation risk, not a nit.
//
// Fix: the SIGNER records the real emission time into the shared __commit__ log, and the membrane
// projects that recorded fact. Real AND identical on every device (all read one replicated value).
// Frames written before the field existed fall back to revTime — correct for those, since their revs
// WERE wall-clock TIDs. `time` is frame metadata only, never part of the signed commit.
//
// Run: bare test/firehose-time.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import b4a from 'b4a'
import { SwarmDevice } from '../lib/swarm-device.js'
import { revTime } from 'atproto-room/lib/serve-membrane.js'
import { nextRev, tidMicros } from 'atproto-store/lib/tid.js'

const DID = 'did:plc:firehosetime222222222222'
const KEY = (() => { const k = b4a.alloc(32); k[31] = 11; return k })()
const POST = 'app.bsky.feed.post'
const post = (t) => ({ $type: POST, text: t, createdAt: '2026-07-24T00:00:00.000Z' })
const S32 = '234567abcdefghijklmnopqrstuvwxyz'
const tidFor = (ms) => { let n = (BigInt(ms) * 1000n) << 10n; let s = ''; for (let i = 0; i < 13; i++) { s = S32[Number(n % 32n)] + s; n /= 32n } return s }

function memKV () {
  const m = new Map()
  return { map: m,
    base: { writable: true },
    async set (k, v) { m.set(k, v) }, async del (k) { m.delete(k) },
    async get (k) { return m.has(k) ? m.get(k) : null }, async getNow (k) { return this.get(k) },
    async list (p = '') { const o = {}; for (const [k, v] of m) if (k.startsWith(p)) o[k] = v; return o },
    async listNow (p = '') { return this.list(p) },
    holdsSigner: () => true }
}
const frames = (sb) => [...sb.map.keys()].filter((k) => k.startsWith('__commit__/')).sort().map((k) => sb.map.get(k))

test('a signed #commit frame records the REAL emission time', async (t) => {
  const sb = memKV()
  const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })
  const before = Date.now()
  await A.putRecord(POST, '3a', post('a')); await A.sign()
  await A.putRecord(POST, '3b', post('b')); await A.sign()
  const after = Date.now()

  const fs = frames(sb)
  t.is(fs.length, 2, 'two frames emitted')
  for (const f of fs) {
    t.ok(typeof f.time === 'string' && !Number.isNaN(Date.parse(f.time)), `frame ${f.rev} carries a parseable ISO time`)
    const ts = Date.parse(f.time)
    t.ok(ts >= before - 1000 && ts <= after + 1000, `frame ${f.rev} time is the real emission instant, not derived`)
  }
})

test('on an OLD chain the recorded time stays current while a derived one would be stale', async (t) => {
  const sb = memKV()
  const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })

  // An account whose chain head was minted 30 days ago — the state of a live DID after deterministic
  // revs ship. Every subsequent rev is prev+1µs, so revTime() can never leave that origin.
  //
  // The head has to be old in the LOG, not just in memory: sign()'s forward-only gate re-anchors
  // this.head to the highest-rev frame it can see, so poking A.head alone is correctly overridden.
  // And tidNow() carries a process-global monotonic floor, so an injected old clock can't produce an
  // old rev either. So: sign normally, then re-key that frame under a 30-day-old rev.
  await A.putRecord(POST, '3a', post('a')); await A.sign()
  const oldRev = tidFor(Date.now() - 30 * 86400000)
  const [freshKey] = [...sb.map.keys()].filter((k) => k.startsWith('__commit__/'))
  const seeded = { ...sb.map.get(freshKey), rev: oldRev }
  sb.map.delete(freshKey)
  sb.map.set('__commit__/' + oldRev, seeded)

  // A fresh device on that log restores the old head, then continues deterministically from it.
  const B = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'B', firehose: true })
  await B.putRecord(POST, '3b', post('b'))
  const h = await B.sign()

  t.ok(h && h.rev === nextRev(oldRev), 'the new rev continues deterministically from the 30-day-old head')
  const f = frames(sb).find((x) => x.rev === h.rev)
  t.ok(f, 'frame for the continued rev exists')

  const derivedStaleDays = (Date.now() - Date.parse(revTime(f.rev))) / 86400000
  t.ok(derivedStaleDays > 29, `deriving time from the rev would be ~30 days stale (was ${derivedStaleDays.toFixed(1)}d)`)
  const recordedStaleSec = (Date.now() - Date.parse(f.time)) / 1000
  t.ok(Math.abs(recordedStaleSec) < 60, `the RECORDED time is current (${recordedStaleSec.toFixed(1)}s off)`)
  t.ok(tidMicros(f.rev) > 0n, 'rev is still a valid TID')
})

test('a legacy frame with no recorded time falls back to revTime (back-compat)', async (t) => {
  const sb = memKV()
  const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })
  await A.putRecord(POST, '3a', post('a')); await A.sign()

  const f = frames(sb)[0]
  const legacy = { ...f }; delete legacy.time // a frame written before the field existed
  // This is exactly the expression serve-membrane's projectFirehose uses.
  t.is(legacy.time || revTime(legacy.rev), revTime(f.rev), 'projection falls back to the rev-derived time')
  t.ok(f.time && (f.time || revTime(f.rev)) === f.time, 'a modern frame prefers its recorded time')
})