OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / serve-device.test.js

94 lines · javascript ref:
// The control surface: a client/app submits a write to a running SwarmDevice and observes it, over
// the real swarm RPC (serveDevice), gated by the canWrite allowlist — the multi-device analog of
// lib/peer.js apply-write. Isolated from the (separately-proven, multi-device.test.js) attestation
// admission path by backing the device with an in-memory KV, so this test targets ONLY the
// transport: get-state (always), apply-write (authorized lands; unauthorized rejected).

import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import c from 'compact-encoding'
import Hyperswarm from 'hyperswarm'
import ProtomuxRPC from 'protomux-rpc'
import createTestnet from '@hyperswarm/testnet'
import { SwarmDevice } from '../lib/swarm-device.js'
import { serveDevice, DEVICE_PROTOCOL } from '../lib/serve-device.js'

const DID = 'did:plc:control22222222222222222'
const POST = 'app.bsky.feed.post'

// in-memory KV statebase — RepoBase/Lease only need async set/del/get/list.
function memKV () {
  const m = new Map()
  return {
    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 list (prefix = '') { const o = {}; for (const [k, v] of m) if (k.startsWith(prefix)) o[k] = v; return o }
  }
}

function clientRpc (swarm, serverKeyHex) {
  return new Promise((resolve) => {
    swarm.on('connection', (conn) => resolve(new ProtomuxRPC(conn, { protocol: DEVICE_PROTOCOL, valueEncoding: c.json })))
    swarm.join(b4a.from(serverKeyHex, 'hex'), { client: true, server: false })
  })
}

test('device control surface: gated apply-write + observable get-state over swarm', async (t) => {
  const testnet = await createTestnet(3)
  const device = new SwarmDevice(memKV(), { did: DID, privateKey: crypto.randomBytes(32), self: 'deviceA' })

  const serverSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
  const authedClient = new Hyperswarm({ bootstrap: testnet.bootstrap })
  const strangerClient = new Hyperswarm({ bootstrap: testnet.bootstrap })
  const authedPub = b4a.toString(authedClient.keyPair.publicKey, 'hex')

  // canWrite allowlist = only the authed client's Noise pubkey (secure default is null elsewhere).
  const serving = serveDevice(device, serverSwarm, { canWrite: (hex) => hex === authedPub })
  await serving.ready()
  t.teardown(async () => {
    serving.close()
    await serverSwarm.destroy(); await authedClient.destroy(); await strangerClient.destroy()
    await testnet.destroy()
  })

  const rpc = await clientRpc(authedClient, serving.serverKey)

  // get-state before any write — observable, empty.
  const s0 = await rpc.request('get-state', {})
  t.is(s0.did, DID, 'get-state reports the DID')
  t.is(s0.recordCount, 0, 'no records before write')

  // authorized apply-write lands the intent.
  const rec = { $type: POST, text: 'via control surface', createdAt: '2026-07-04T00:00:00.000Z' }
  const w = await rpc.request('apply-write', { action: 'create', collection: POST, rkey: '3ctrl', record: rec })
  t.is(w.recordCount, 1, 'authorized write landed')

  // get-state now reflects it (keys only).
  const s1 = await rpc.request('get-state', {})
  t.is(s1.recordCount, 1, 'record count updated')
  t.alike(s1.records[0], { collection: POST, rkey: '3ctrl' }, 'record observable via get-state')

  // a non-allowlisted client is refused write but may still read.
  const rpc2 = await clientRpc(strangerClient, serving.serverKey)
  const s2 = await rpc2.request('get-state', {})
  t.is(s2.recordCount, 1, 'stranger may read state')
  // gating: the stranger's write is refused (the RPC rejects; protomux does not forward the
  // server message, so assert by behavior) and does NOT land — record count is unchanged.
  await t.exception(() => rpc2.request('apply-write', { action: 'create', collection: POST, rkey: 'x', record: rec }), 'stranger write rejected')
  const s3 = await rpc2.request('get-state', {})
  t.is(s3.recordCount, 1, 'rejected write did not land')

  // pointer-diag is a READ, but it is gated with the WRITES rather than published with get-state:
  // it hashes every record file, so an ungated responder would hand any peer that knows the DID a free
  // CPU-exhaustion trigger (the same reason reindex-overlay stays UDS-only). Gating it on the existing
  // trusted-key allowlist is what makes `hyper-proto-peer diag --remote` able to ask "do your bytes match
  // your pointers?" without an ssh session, while keeping the cost behind a key.
  const pd = await rpc.request('pointer-diag', {})
  t.ok(pd, 'an ALLOWLISTED caller reaches pointer-diag remotely')
  t.is(pd.skipped, 'no-record-store', 'and gets the device\'s real answer (this device has no record store)')
  await t.exception(() => rpc2.request('pointer-diag', {}), 'a stranger is refused pointer-diag — it is not a public read')
})