#!/usr/bin/env node
/**
* test-poker-ws-adapter.js
*
* Validates PokerWsAdapter against a real http.Server + ws client:
*
* 1. Path matcher accepts /api/poker/<hex64>/events and nothing else.
* 2. Connect to a known table → receives an initial { type:'state' } frame.
* 3. A submitted entry on that table is pushed to the connected client
* as { type:'entry', entry, index } in order.
* 4. Two clients on the same table both receive the same entry.
* 5. A client on a different table does NOT receive the entry.
* 6. Connecting to a non-existent table is rejected at handshake with 404.
* 7. stop() detaches subscriptions and closes connections cleanly.
* 8. API-key gate rejects URL tokens, waits for in-band auth, and rejects bad frames.
*/
import http from 'http'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import WebSocket from 'ws'
import { SignedLog } from '../packages/services/builtin/poker/signed-log.js'
import { PokerApp } from '../packages/services/builtin/poker/index.js'
import { PokerWsAdapter, _matchTableKeyForTest as matchTableKey } from '../packages/services/builtin/poker/ws-adapter.js'
let passed = 0
let failed = 0
function assert (condition, label) {
if (condition) { console.log(` PASS ${label}`); passed++ }
else { console.log(` FAIL ${label}`); failed++ }
}
function makeKeyPair () {
const publicKey = b4a.alloc(32)
const secretKey = b4a.alloc(64)
sodium.crypto_sign_keypair(publicKey, secretKey)
return { publicKey, secretKey, pubHex: b4a.toString(publicKey, 'hex') }
}
function signEntry (kp, entry) {
const canonical = SignedLog.canonicalBytes(entry)
const sig = b4a.alloc(64)
sodium.crypto_sign_detached(sig, canonical, kp.secretKey)
return { ...entry, signature: b4a.toString(sig, 'hex') }
}
/**
* Buffer every frame the client receives, starting NOW. Avoids the race
* where a frame is delivered before a per-call `ws.on('message')` listener
* is attached (the initial /state frame is sent during the upgrade
* response and can land before the client even sees 'open').
*
* Returns an awaitable: `await capture.next(predicate)` resolves to the
* next frame matching `predicate`, or null on timeout.
*/
function startCapture (ws) {
const frames = []
const waiters = []
ws.on('message', (raw) => {
let msg
try { msg = JSON.parse(raw.toString('utf8')) } catch { return }
frames.push(msg)
for (const w of waiters.slice()) {
if (w.predicate(msg)) {
waiters.splice(waiters.indexOf(w), 1)
clearTimeout(w.timer)
w.resolve(msg)
}
}
})
return {
frames,
next (predicate, timeoutMs = 500) {
// Already-buffered match?
for (const m of frames) if (predicate(m)) return Promise.resolve(m)
return new Promise((resolve) => {
const w = { predicate, resolve, timer: null }
w.timer = setTimeout(() => {
const idx = waiters.indexOf(w)
if (idx >= 0) waiters.splice(idx, 1)
resolve(null)
}, timeoutMs)
waiters.push(w)
})
}
}
}
// ── 1. matchTableKey ────────────────────────────────────────────────────────
function testMatchKey () {
console.log('\n── 1. matchTableKey ──')
const valid = 'a'.repeat(64)
assert(matchTableKey('/api/poker/' + valid + '/events') === valid, 'happy path')
assert(matchTableKey('/api/poker/' + valid + '/events/extra') === null, 'trailing junk rejected')
assert(matchTableKey('/api/poker//events') === null, 'empty key rejected')
assert(matchTableKey('/api/poker/not-hex/events') === null, 'non-hex rejected')
assert(matchTableKey('/api/poker/' + valid.slice(1) + '/events') === null, 'wrong length rejected')
assert(matchTableKey('/api/something/' + valid + '/events') === null, 'wrong prefix rejected')
}
// ── 2..7. Full integration over a real http.Server ─────────────────────────
async function integration () {
console.log('\n── 2..7. Integration over http.Server + ws ──')
const a = makeKeyPair()
const TABLE = 'a'.repeat(64)
const OTHER_TABLE = 'b'.repeat(64)
const app = new PokerApp()
await app.start({})
app.createTable({ tableKey: TABLE, writers: [a.pubHex] })
app.createTable({ tableKey: OTHER_TABLE, writers: [a.pubHex] })
const server = http.createServer()
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
const adapter = new PokerWsAdapter({ pokerApp: app, server })
adapter.start()
// 2. Connect; receive initial state. Attach capture BEFORE open await so
// the state frame (sent during the upgrade response) isn't missed.
const c1 = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + TABLE + '/events')
const cap1 = startCapture(c1)
await new Promise((r) => c1.once('open', r))
const state = await cap1.next((m) => m.type === 'state')
assert(state && state.state.tableKey === TABLE, 'initial state frame on connect')
// 3. Push an entry; client gets it.
const now = Date.now()
const entry = signEntry(a, {
tableKey: TABLE, writer: a.pubHex, seq: 0, ts: now, payload: { kind: 'sit', seat: 0 }
})
const submitResult = app.submitEntry(TABLE, entry)
assert(submitResult.ok, 'submitEntry ok')
const frame = await cap1.next((m) => m.type === 'entry')
assert(frame && frame.entry.payload.kind === 'sit', 'client received entry frame with payload')
assert(frame && frame.index === 0, 'frame index = 0')
// 4. Second client on same table also gets next entry.
const c2 = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + TABLE + '/events')
const cap2 = startCapture(c2)
await new Promise((r) => c2.once('open', r))
await cap2.next((m) => m.type === 'state')
assert(adapter.clientCount(TABLE) === 2, 'two clients on same table')
const entry2 = signEntry(a, {
tableKey: TABLE, writer: a.pubHex, seq: 1, ts: now, payload: { kind: 'bet', amount: 5 }
})
app.submitEntry(TABLE, entry2)
const [f1, f2] = await Promise.all([
cap1.next((m) => m.type === 'entry' && m.index === 1),
cap2.next((m) => m.type === 'entry' && m.index === 1)
])
assert(f1 && f2 && f1.entry.payload.amount === 5 && f2.entry.payload.amount === 5,
'both clients received entry 1')
// 5. Client on OTHER_TABLE does NOT see this entry.
const cOther = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + OTHER_TABLE + '/events')
const capOther = startCapture(cOther)
await new Promise((r) => cOther.once('open', r))
await capOther.next((m) => m.type === 'state')
const noLeak = await capOther.next((m) => m.type === 'entry', 200)
assert(noLeak === null, 'other-table client received no leaked entry')
// 6. Non-existent table → handshake 404.
const missing = 'c'.repeat(64)
const ws404 = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + missing + '/events')
const err404 = await new Promise((r) => {
ws404.on('unexpected-response', (_req, res) => r(res.statusCode))
ws404.on('error', () => r('error-event'))
setTimeout(() => r('timeout'), 1000)
})
assert(err404 === 404, 'missing table → 404 at handshake (' + err404 + ')')
// 7. Close clients, then stop adapter.
c1.close(); c2.close(); cOther.close()
await new Promise(r => setTimeout(r, 50))
adapter.stop()
assert(adapter.clientCount() === 0, 'all clients detached after stop')
await new Promise(r => server.close(r))
await app.stop()
}
// ── 8. API key gate ────────────────────────────────────────────────────────
async function testApiKeyGate () {
console.log('\n── 8. API key gate ──')
const a = makeKeyPair()
const TABLE = 'a'.repeat(64)
const app = new PokerApp()
await app.start({})
app.createTable({ tableKey: TABLE, writers: [a.pubHex] })
const server = http.createServer()
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const port = server.address().port
const adapter = new PokerWsAdapter({ pokerApp: app, server, apiKey: 'sekrit', authTimeoutMs: 100 })
adapter.start()
// URL token → 401. Credentials must not ride in URLs.
let ws = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + TABLE + '/events?token=sekrit')
let status = await new Promise((r) => {
ws.on('unexpected-response', (_req, res) => r(res.statusCode))
ws.on('error', () => r('error-event'))
setTimeout(() => r('timeout'), 1000)
})
assert(status === 401, 'URL token → 401 (' + status + ')')
// No auth frame → closes before state is sent.
ws = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + TABLE + '/events')
const capNoAuth = startCapture(ws)
await new Promise((r) => ws.once('open', r))
const noAuthClose = await new Promise((r) => {
ws.once('close', (code, reason) => r({ code, reason: reason.toString() }))
setTimeout(() => r({ code: 'timeout', reason: '' }), 1000)
})
assert(noAuthClose.code === 1008 && noAuthClose.reason === 'auth-required', 'no auth frame → policy close')
assert(capNoAuth.frames.length === 0, 'no state frame before auth')
// Wrong auth frame → closes.
ws = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + TABLE + '/events')
await new Promise((r) => ws.once('open', r))
ws.send(JSON.stringify({ type: 'auth', token: 'wrong' }))
const badAuthClose = await new Promise((r) => {
ws.once('close', (code, reason) => r({ code, reason: reason.toString() }))
setTimeout(() => r({ code: 'timeout', reason: '' }), 1000)
})
assert(badAuthClose.code === 1008 && badAuthClose.reason === 'auth-failed', 'bad auth frame → policy close')
// Right in-band auth → opens and receives state.
ws = new WebSocket('ws://127.0.0.1:' + port + '/api/poker/' + TABLE + '/events')
const capGood = startCapture(ws)
await new Promise((r) => ws.once('open', r))
ws.send(JSON.stringify({ type: 'auth', token: 'sekrit' }))
const state = await capGood.next((m) => m.type === 'state')
assert(state && state.state.tableKey === TABLE, 'good in-band auth → state frame')
ws.close()
await new Promise(r => setTimeout(r, 50))
adapter.stop()
await new Promise(r => server.close(r))
await app.stop()
}
async function main () {
testMatchKey()
await integration()
await testApiKeyGate()
console.log(`\n── done: ${passed} passed, ${failed} failed ──`)
process.exit(failed === 0 ? 0 : 1)
}
main().catch((err) => {
console.error('fatal:', err)
process.exit(2)
})