OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / poker / index.js

599 lines · javascript ref:
/**
 * PokerApp — relay-side substrate for turn-based, card-blind, signed-log
 * applications (poker is the first consumer; liar's dice, mafia, sealed-bid
 * markets fit the same shape).
 *
 * The relay's job here is intentionally narrow:
 *
 *   - Hold a per-table append-only SignedLog (see ./signed-log.js).
 *   - Enforce signature + per-writer ordering + clock skew + byte budget.
 *   - Serve `/state`, `/log?from=`, accept `/move`, emit subscriber events.
 *   - Pin the resulting log via the existing seeder + custody pipeline.
 *
 * The relay is **card-blind**: payloads are opaque. Hole cards, decryption
 * shares, shuffle proofs, action bytes — all of it is `entry.payload`, which
 * the relay never reads. Cryptographic hand evaluation happens in the
 * players' Pear apps, off the log.
 *
 * ─── Lifecycle ──────────────────────────────────────────────────────────────
 *
 *   const app = new PokerApp({ maxTables, defaultLifetimeMs })
 *   await app.start({ node: relayNode })       // ServiceProvider hook
 *   const table = app.createTable({ tableKey, writers })
 *   const r = app.submitEntry(tableKey, signedEntry)
 *   //   → { ok: true, index, ts } | { ok: false, reason, detail }
 *   const view = app.getLog(tableKey, fromIdx)
 *   const st = app.getState(tableKey)
 *   const off = app.subscribe(tableKey, entry => {...})
 *   await app.stop()
 *
 * The ServiceProvider methods (`manifest`, `start`, `stop`) match the
 * existing plugin-loader contract so PokerApp can be configured into a
 * relay via the same `plugins:` list operators already use.
 *
 * ─── Storage / persistence ──────────────────────────────────────────────────
 *
 * In-memory only. Production persistence is a separate concern wired by the
 * operator — they can:
 *
 *   a) Snapshot the log periodically and re-hydrate via `SignedLog._replay`.
 *   b) Mirror entries into a hypercore + replicate via the existing seeder.
 *   c) Mirror entries into an autobase (the architecture-doc preferred
 *      shape) so each player writes to their own core.
 *
 * The default `PokerApp` shipped here keeps tables in memory because the
 * substrate is the same regardless of persistence — making memory the
 * default keeps the MVP path simple and lets the operator pick the right
 * persistence for their workload without fighting the framework.
 *
 * ─── Why a "signed log" rather than "game state"? ───────────────────────────
 *
 * Server-authoritative game state (the hiveworm pattern) is wrong for poker:
 * the relay would have to see card values, which breaks the security model.
 * A signed log keeps the relay card-blind by construction. Any number of
 * other turn-based games with hidden-info structure (liar's dice, blind
 * auctions, secret-roles party games) fit the same shape, so the substrate
 * is named `PokerApp` for the consumer but is fundamentally generic.
 *
 * Renaming follow-up: when a second consumer lands, lift this to
 * `SignedLogApp` and have `PokerApp` extend it with poker-specific niceties
 * (default writer counts, schema hooks, dispute-evidence builders).
 */

import { ServiceProvider } from 'p2p-hiverelay/core/services/provider.js'
import { SignedLog, REJECT } from './signed-log.js'
import { hashControlOptions, MAX_PRESENCE_VALIDITY_MS, verifySignedLogControl } from './control-capability.js'

const DEFAULT_MAX_TABLES = 1024
const DEFAULT_TABLE_LIFETIME_MS = 24 * 60 * 60 * 1000 // 24h, see seeding-manifest 'session'
const DEFAULT_TABLE_REAPER_INTERVAL_MS = 5 * 60 * 1000 // every 5 min

export class PokerApp extends ServiceProvider {
  /**
   * @param {object} [opts]
   * @param {number} [opts.maxTables]            Soft cap on concurrent tables.
   * @param {number} [opts.defaultLifetimeMs]    Idle TTL for a table.
   * @param {number} [opts.reaperIntervalMs]     How often to evict idle tables.
   * @param {number} [opts.maxEntriesPerTable]   Forwarded to SignedLog.
   * @param {(label, info) => void} [opts.log]   Optional structured logger.
   */
  constructor (opts = {}) {
    super()
    this.maxTables = opts.maxTables || DEFAULT_MAX_TABLES
    this.defaultLifetimeMs = opts.defaultLifetimeMs || DEFAULT_TABLE_LIFETIME_MS
    this.reaperIntervalMs = opts.reaperIntervalMs || DEFAULT_TABLE_REAPER_INTERVAL_MS
    this.maxEntriesPerTable = opts.maxEntriesPerTable || undefined
    this._log = opts.log || (() => {})

    /** @type {Map<string, {log: SignedLog, options: object, idleAt: ?number, presence: Map}>} */
    this._tables = new Map()
    this._closedTables = new Map()
    /** @type {?ReturnType<typeof setInterval>} */
    this._reaper = null
    /** @type {?object} Relay node (set by start) for pubsub/seeder hooks. */
    this.node = null
  }

  // ─── ServiceProvider interface ────────────────────────────────────────────

  manifest () {
    return {
      name: 'poker',
      version: '0.3.0',
      description: 'Card-blind signed-log substrate for turn-based games',
      capabilities: [
        'createTable', 'submitEntry', 'getLog', 'getState', 'listTables',
        'createAuthorized', 'requestWriter', 'grantWriter', 'revokeWriter', 'closeAuthorized',
        'announcePresence', 'getPresence'
      ]
    }
  }

  /**
   * Wire up to the relay node. `context.node` is the RelayNode instance —
   * gives us pubsub for emitting table events the WS gateway can fan out.
   */
  async start (context = {}) {
    this.node = context.node || null
    this._reaper = setInterval(() => this._reap(), this.reaperIntervalMs)
    // unref so the reaper doesn't keep the process alive in tests.
    if (this._reaper.unref) this._reaper.unref()
  }

  async stop () {
    if (this._reaper) {
      clearInterval(this._reaper)
      this._reaper = null
    }
    this._tables.clear()
    this._closedTables.clear()
    this.node = null
  }

  // ─── Public API ───────────────────────────────────────────────────────────

  /**
   * Register a new table. The caller (out-of-band) is responsible for
   * deciding the table's keypair and broadcasting the table key to invited
   * players via whatever pairing channel the app uses.
   *
   * @param {object} args
   * @param {string} args.tableKey      Hex pubkey identifying the table.
   * @param {string[]} args.writers     Allowed writer pubkeys (hex), N seats.
   * @param {object} [args.options]     App-level options (blinds, etc.) —
   *                                    OPAQUE to the relay; held verbatim.
   * @returns {object} Public table descriptor.
   */
  createTable (args) {
    if (!args || typeof args !== 'object') throw new Error('createTable: bad args')
    const requestedKey = String(args.tableKey).toLowerCase()
    if (this._closedTables.has(requestedKey)) {
      throw new Error('createTable: table closed')
    }
    if (this._tables.has(requestedKey)) {
      throw new Error('createTable: table already exists')
    }
    if (this._tables.size >= this.maxTables) {
      throw new Error('createTable: max tables reached')
    }
    const log = new SignedLog({
      tableKey: args.tableKey,
      writers: args.writers,
      maxEntries: this.maxEntriesPerTable,
      log: this._log
    })
    const record = {
      log,
      options: args.options ? deepFreeze(JSON.parse(JSON.stringify(args.options))) : {},
      idleAt: Date.now() + this.defaultLifetimeMs,
      authority: args.authority ? String(args.authority).toLowerCase() : null,
      controlRevision: Number.isSafeInteger(args.controlRevision) ? args.controlRevision : null,
      presence: new Map()
    }
    // Subscribe internally so any successful append resets the idle timer
    // and (optionally) publishes onto the node's pubsub for WS fan-out.
    log.subscribe((entry, index) => {
      record.idleAt = Date.now() + this.defaultLifetimeMs
      this._emit(log.tableKey, entry, index)
    })
    this._tables.set(log.tableKey, record)
    return this._publicDescriptor(log.tableKey, record)
  }

  createAuthorized (args) {
    if (!args || typeof args !== 'object') return { ok: false, reason: 'bad-control' }
    const checked = verifySignedLogControl(args.control, {
      action: 'create', tableKey: args.tableKey, revision: 0
    })
    if (!checked.ok) return checked
    const control = checked.control
    const writers = normalizeWriters(args.writers)
    if (!writers || JSON.stringify(writers) !== JSON.stringify(control.writers)) {
      return { ok: false, reason: 'control-writers-mismatch' }
    }
    if (hashControlOptions(args.options || {}) !== control.optionsHash) {
      return { ok: false, reason: 'control-options-mismatch' }
    }
    if (this._closedTables.has(control.tableKey)) return { ok: false, reason: 'table-closed' }
    // A legacy unsigned create could otherwise squat a public table key before
    // its owner arrives. Possession of the table secret proves authority, so an
    // authenticated create may replace only an unauthorised legacy placeholder.
    const existing = this._get(control.tableKey)
    if (existing && existing.authority) return { ok: false, reason: 'table-exists' }
    if (existing) this._tables.delete(control.tableKey)
    try {
      const table = this.createTable({
        tableKey: control.tableKey,
        writers,
        options: args.options,
        authority: control.authority,
        controlRevision: control.revision
      })
      return { ok: true, table }
    } catch (err) {
      return { ok: false, reason: createFailureReason(err) }
    }
  }

  grantWriter (args) {
    return this._applyWriterControl(args, 'grant')
  }

  requestWriter (args) {
    if (!args || typeof args !== 'object') return { ok: false, reason: 'bad-control' }
    const record = this._get(args.tableKey)
    if (!record) return { ok: false, reason: 'no-such-table' }
    if (!record.authority) return { ok: false, reason: 'control-unsupported' }
    const checked = verifySignedLogControl(args.control, {
      action: 'request', tableKey: record.log.tableKey, revision: 0
    })
    if (!checked.ok) return checked
    this._emitControlRequest(record.log.tableKey, checked.control)
    return { ok: true, requested: checked.control.writer }
  }

  revokeWriter (args) {
    return this._applyWriterControl(args, 'revoke')
  }

  announcePresence (args) {
    if (!args || typeof args !== 'object') return { ok: false, reason: 'bad-control' }
    const record = this._get(args.tableKey)
    if (!record) return { ok: false, reason: 'no-such-table' }
    const checked = verifySignedLogControl(args.control, {
      action: 'presence', tableKey: record.log.tableKey
    })
    if (!checked.ok) return checked
    const presence = checked.control
    if (!record.log.writers.has(presence.authority)) return { ok: false, reason: 'unknown-writer' }
    if (presence.expiresAt - presence.issuedAt > MAX_PRESENCE_VALIDITY_MS) {
      return { ok: false, reason: 'presence-validity-too-long' }
    }
    const prior = record.presence.get(presence.authority)
    if (prior) {
      const sameInstance = prior.instance === presence.instance
      if ((sameInstance && presence.revision <= prior.revision) ||
          (!sameInstance && presence.issuedAt <= prior.issuedAt)) {
        return { ok: false, reason: 'stale-presence' }
      }
    }
    record.presence.set(presence.authority, presence)
    record.idleAt = Date.now() + this.defaultLifetimeMs
    this._emitPresence(record.log.tableKey, presence)
    return { ok: true, presence }
  }

  getPresence (args) {
    const tableKey = args && typeof args === 'object' ? args.tableKey : args
    const record = this._get(tableKey)
    if (!record) return { ok: false, reason: 'no-such-table' }
    const now = Date.now()
    this._prunePresence(record, now)
    return { ok: true, tableKey: record.log.tableKey, now, presence: [...record.presence.values()] }
  }

  closeAuthorized (args) {
    if (!args || typeof args !== 'object') return { ok: false, reason: 'bad-control' }
    const record = this._get(args.tableKey)
    if (!record) {
      const closed = this._closedTables.get(String(args.tableKey || '').toLowerCase())
      return closed ? { ok: true, already: true, closure: closed } : { ok: false, reason: 'no-such-table' }
    }
    if (!record.authority) return { ok: false, reason: 'control-unsupported' }
    const checked = verifySignedLogControl(args.control, {
      action: 'close',
      tableKey: record.log.tableKey,
      authority: record.authority,
      revision: record.controlRevision + 1
    })
    if (!checked.ok) return checked
    const closure = Object.freeze({
      tableKey: record.log.tableKey,
      authority: record.authority,
      revision: checked.control.revision,
      closedAt: Date.now(),
      control: checked.control
    })
    this._tables.delete(record.log.tableKey)
    this._closedTables.set(record.log.tableKey, closure)
    this._emitControl(record.log.tableKey, { type: 'closed', closure })
    return { ok: true, closure }
  }

  /**
   * Try to append a signed entry to a table's log.
   * Returns the SignedLog.append result verbatim.
   *
   * Dual-convention: the P2P service RPC dispatches every capability as
   * `method(params, context)` — a single params object — whereas the HTTP
   * adapter (and direct callers) pass positional args. When the first arg is
   * a params object we read the fields from it; otherwise we treat it as the
   * positional tableKey. This keeps poker reachable over BOTH transports.
   */
  submitEntry (tableKey, signedEntry) {
    if (tableKey && typeof tableKey === 'object') {
      signedEntry = tableKey.entry ?? tableKey.signedEntry ?? signedEntry
      tableKey = tableKey.tableKey
    }
    const record = this._get(tableKey)
    if (!record) {
      const key = typeof tableKey === 'string' ? tableKey.toLowerCase() : ''
      return { ok: false, reason: this._closedTables.has(key) ? 'table-closed' : 'no-such-table' }
    }
    return record.log.append(signedEntry)
  }

  /**
   * Read entries from `fromIdx`. Returns `{ from, to, entries }`.
   * Dual-convention (see submitEntry): accepts `{ tableKey, from, limit }`
   * from the service RPC as well as positional `(tableKey, from, limit)`.
   */
  getLog (tableKey, fromIdx = 0, limit = Infinity) {
    if (tableKey && typeof tableKey === 'object') {
      const p = tableKey
      tableKey = p.tableKey
      fromIdx = p.from ?? p.fromIdx ?? 0
      limit = p.limit ?? Infinity
    }
    const record = this._get(tableKey)
    if (!record) return null
    return record.log.slice(fromIdx, limit)
  }

  // Dual-convention (see submitEntry): accepts `{ tableKey }` or positional.
  getState (tableKey) {
    if (tableKey && typeof tableKey === 'object') tableKey = tableKey.tableKey
    const record = this._get(tableKey)
    if (!record) {
      const closure = this._closedTables.get(String(tableKey || '').toLowerCase())
      return closure ? { tableKey: closure.tableKey, closed: true, closure } : null
    }
    return {
      ...record.log.state(),
      options: record.options,
      idleAt: record.idleAt,
      authority: record.authority,
      controlRevision: record.controlRevision,
      closed: false
    }
  }

  /**
   * Subscribe to new entries for a single table. Returns an unsubscribe.
   */
  subscribe (tableKey, fn) {
    const record = this._get(tableKey)
    if (!record) throw new Error('subscribe: no such table')
    return record.log.subscribe(fn)
  }

  /**
   * Public list of tables — used by `/api/poker/tables`. We only return
   * the table key, writer count, and last-activity timestamp. App options
   * may leak game configuration so they're behind the per-table state read.
   */
  listTables () {
    const out = []
    for (const [key, record] of this._tables) {
      const st = record.log.state()
      out.push({
        tableKey: key,
        writers: st.writers ? Object.keys(st.writers).length : 0,
        lastTs: st.lastTs,
        length: st.length,
        idleAt: record.idleAt
      })
    }
    return out
  }

  /**
   * Manually drop a table — used by the operator UI or by app glue when a
   * hand finishes and the players want their ephemeral session cleared
   * sooner than the reaper would.
   */
  closeTable (tableKey) {
    return this._tables.delete(String(tableKey).toLowerCase())
  }

  _applyWriterControl (args, action) {
    if (!args || typeof args !== 'object') return { ok: false, reason: 'bad-control' }
    const record = this._get(args.tableKey)
    if (!record) return { ok: false, reason: 'no-such-table' }
    if (!record.authority) return { ok: false, reason: 'control-unsupported' }
    const checked = verifySignedLogControl(args.control, {
      action,
      tableKey: record.log.tableKey,
      authority: record.authority,
      revision: record.controlRevision + 1
    })
    if (!checked.ok) return checked
    const result = action === 'grant'
      ? record.log.addWriter(checked.control.writer)
      : record.log.removeWriter(checked.control.writer)
    if (!result.ok) return result
    record.controlRevision = checked.control.revision
    record.idleAt = Date.now() + this.defaultLifetimeMs
    this._emitControl(record.log.tableKey, { type: action, control: checked.control })
    return { ok: true, changed: result.changed, table: this._publicDescriptor(record.log.tableKey, record) }
  }

  /**
   * Rehydrate a table's log from an array of already-validated entries.
   * Used by persistence adapters at startup to replay a stored stream
   * into the in-memory SignedLog.
   *
   * SAFETY: the entries are NOT re-validated. The caller is responsible for
   * having validated them on the way INTO the store originally (this is the
   * standard pattern — validate at ingest, trust at hydrate). Only call
   * this from your own persistence layer, never from user input.
   *
   * @param {string} tableKey
   * @param {object[]} entries Entries in the same shape SignedLog.append
   *   accepts (already signed). Order matters — per-writer seq order must
   *   be preserved.
   * @returns {number} Number of entries replayed.
   */
  replayEntries (tableKey, entries) {
    const record = this._get(tableKey)
    if (!record) throw new Error('replayEntries: no such table')
    if (!Array.isArray(entries)) throw new Error('replayEntries: entries must be an array')
    record.log._replay(entries)
    return entries.length
  }

  // ─── Internal ─────────────────────────────────────────────────────────────

  _get (tableKey) {
    if (typeof tableKey !== 'string') return null
    return this._tables.get(tableKey.toLowerCase()) || null
  }

  /**
   * Public descriptor returned from createTable — same shape as listTables
   * entries plus the (opaque) options.
   */
  _publicDescriptor (tableKey, record) {
    const st = record.log.state()
    return {
      tableKey,
      writers: Array.from(record.log.writers),
      options: record.options,
      lastTs: st.lastTs,
      length: st.length,
      idleAt: record.idleAt,
      authority: record.authority,
      controlRevision: record.controlRevision
    }
  }

  /**
   * Fan a successful append out via the relay's pubsub if one exists.
   * Best-effort — failure to publish does not affect log state.
   *
   * Topic is PER-TABLE (`poker/<tableKey>`) so a subscriber receives only the
   * table it asked for via `client.subscribeService('poker', tableKey, …)` —
   * never a cross-table firehose. No new exposure: the same entries are already
   * readable from the open `GET /api/poker/<table>/log`, and card-blindness
   * holds because the payload stays whatever the players signed (the relay
   * neither reads nor adds to it). Only live appends are published — `_replay`
   * (hydration) does not notify subscribers, so restarts don't re-emit history.
   */
  _emit (tableKey, entry, index) {
    if (!this.node || !this.node.router || !this.node.router.pubsub) return
    try {
      this.node.router.pubsub.publish('poker/' + tableKey, { tableKey, index, entry })
    } catch (err) {
      this._log('emit-error', { error: err && err.message })
    }
  }

  _emitControl (tableKey, event) {
    if (!this.node || !this.node.router || !this.node.router.pubsub) return
    try {
      this.node.router.pubsub.publish('poker/control/' + tableKey, { tableKey, ...event })
    } catch (err) {
      this._log('control-emit-error', { error: err && err.message })
    }
  }

  _emitControlRequest (tableKey, control) {
    if (!this.node || !this.node.router || !this.node.router.pubsub) return
    try {
      this.node.router.pubsub.publish('poker/control-request/' + tableKey, { tableKey, type: 'writer-request', control })
    } catch (err) {
      this._log('control-request-emit-error', { error: err && err.message })
    }
  }

  _emitPresence (tableKey, presence) {
    if (!this.node || !this.node.router || !this.node.router.pubsub) return
    try {
      this.node.router.pubsub.publish('poker/presence/' + tableKey, {
        tableKey, type: 'presence', presence, relaySeenAt: Date.now()
      })
    } catch (err) {
      this._log('presence-emit-error', { error: err && err.message })
    }
  }

  _prunePresence (record, now = Date.now()) {
    for (const [writer, presence] of record.presence) {
      if (presence.expiresAt < now) record.presence.delete(writer)
    }
  }

  /**
   * Periodic eviction of idle tables. A table whose idleAt has passed is
   * dropped from memory; the audit log can still be re-fetched from the
   * underlying seeder if the operator wired persistence (out of scope here).
   *
   * Idempotent — safe to call repeatedly. Doesn't throw on lookup misses.
   */
  _reap () {
    const now = Date.now()
    for (const [key, record] of this._tables) {
      if (record.idleAt && record.idleAt < now) {
        this._tables.delete(key)
        this._log('reaped', { tableKey: key })
      }
    }
    for (const [key, closure] of this._closedTables) {
      if (closure.control.expiresAt < now) this._closedTables.delete(key)
    }
  }
}

function normalizeWriters (writers) {
  if (!Array.isArray(writers) || writers.length === 0) return null
  const normalized = new Set()
  for (const writer of writers) {
    if (typeof writer !== 'string' || !/^[0-9a-f]{64}$/i.test(writer)) return null
    normalized.add(writer.toLowerCase())
  }
  return [...normalized].sort()
}

function createFailureReason (err) {
  const message = err && err.message ? err.message : ''
  if (message.includes('already exists')) return 'table-exists'
  if (message.includes('max tables')) return 'max-tables'
  return 'create-failed'
}

// Helper: deep-freeze a JSON-safe object so opaque app options can't be
// mutated through references handed back from getState.
function deepFreeze (o) {
  if (o === null || typeof o !== 'object') return o
  Object.freeze(o)
  for (const k of Object.keys(o)) deepFreeze(o[k])
  return o
}

export { REJECT }
export { SignedLog } from './signed-log.js'
export {
  CONTROL_CLOCK_SKEW_MS,
  MAX_CONTROL_VALIDITY_MS,
  MAX_PRESENCE_VALIDITY_MS,
  SIGNED_LOG_CONTROL_ACTIONS,
  SIGNED_LOG_CONTROL_DOMAIN,
  SIGNED_LOG_CONTROL_VERSION,
  canonicalSignedLogControl,
  hashControlOptions,
  verifySignedLogControl
} from './control-capability.js'

// Verifiable hand-seed helpers. The relay stays card-blind; these let clients
// derive and check an unbiasable per-hand randomness anchor from the VRF
// service and fold it into their own mental-poker shuffle. See ./hand-seed.js.
export {
  handSeedAlpha,
  verifyHandSeed,
  handDeckOrder,
  combineBetas,
  HAND_SEED_DOMAIN,
  DECK_DOMAIN,
  DEFAULT_DECK_SIZE
} from './hand-seed.js'