OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / core / wizard.js

250 lines · javascript ref:
/**
 * First-run setup wizard — state machine + persistence.
 *
 * Guides a fresh operator from "I just installed this" to "my relay is
 * online" in 5 steps:
 *
 *   1. welcome      — user clicks "Let's go"
 *   2. relay_name   — operator picks a name (or accepts the default)
 *   3. payout       — OPTIONAL payout destination (skippable)
 *   4. accept_mode  — choose review/open/allowlist/closed (default: review)
 *   5. complete     — wizard done; main dashboard takes over
 *
 * The payout address feeds `subsidy.payoutDestination`. The relay NEVER
 * holds funds — it accrues a capped sats estimate for blind-peer work and
 * signs claims that a subsidy coordinator independently verifies and pays
 * out to the operator's own address. Because the address is public, it is
 * stored in plaintext (no encryption needed) — unlike the old
 * `lnbits_connect` step, which stored a secret admin key and is now gone.
 *
 * State persists to a small JSON file in the storage dir so container
 * restarts and reinstalls don't reset wizard progress.
 *
 * The wizard is OPTIONAL — relays started via CLI or env-only configs skip
 * it entirely. The HTTP layer checks `wizard.isComplete()` and redirects to
 * /wizard only when the answer is false.
 */

import { EventEmitter } from 'events'
import { readFile, writeFile, rename, mkdir, chmod } from 'fs/promises'
import { dirname, basename, join } from 'path'
import { validatePayoutDestination } from '../incentive/subsidy/index.js'

const VALID_STEPS = ['welcome', 'relay_name', 'payout', 'accept_mode', 'complete']
const VALID_ACCEPT_MODES = ['open', 'review', 'allowlist', 'closed']
// v3: replaced the encrypted-lnbits-adminKey model (v2) with a plaintext
// payout destination. Older files are loaded forward-compatibly —
// the legacy `lnbits` block is discarded on load.
const SCHEMA_VERSION = 3

export class SetupWizard extends EventEmitter {
  /**
   * @param {object} opts
   * @param {string} opts.storagePath - JSON file path; usually `<storage>/wizard.json`
   * @param {object} [opts.defaults] - default values pre-filled in each step
   */
  constructor (opts = {}) {
    super()
    if (!opts.storagePath) throw new Error('SetupWizard requires storagePath')
    this.storagePath = opts.storagePath
    this.defaults = opts.defaults || {}
    this.state = {
      schemaVersion: SCHEMA_VERSION,
      step: 'welcome',
      relayName: this.defaults.relayName || generateDefaultName(),
      // Public payout destination, or null if skipped.
      payoutDestination: this.defaults.payoutDestination || null,
      acceptMode: 'review',
      startedAt: null,
      completedAt: null
    }
  }

  /**
   * Load existing wizard state from disk. Silently no-ops if the file
   * doesn't exist (first run). Bad files are reset to defaults rather than
   * crashing relay startup. Loads forward-compatibly: only known fields are
   * adopted, and the legacy v2 `lnbits` block is intentionally dropped.
   */
  async load () {
    let raw
    try {
      raw = await readFile(this.storagePath, 'utf8')
    } catch (err) {
      if (err.code === 'ENOENT') return
      throw err
    }
    try {
      const parsed = JSON.parse(raw)
      if (!parsed || typeof parsed !== 'object') return
      // A file saved mid-wizard on the removed 'lnbits_connect' step maps
      // forward to the new 'payout' step.
      const step = parsed.step === 'lnbits_connect' ? 'payout' : parsed.step
      this.state = {
        ...this.state,
        schemaVersion: SCHEMA_VERSION,
        step: VALID_STEPS.includes(step) ? step : this.state.step,
        relayName: typeof parsed.relayName === 'string' ? parsed.relayName : this.state.relayName,
        payoutDestination: typeof parsed.payoutDestination === 'string' ? parsed.payoutDestination : null,
        acceptMode: VALID_ACCEPT_MODES.includes(parsed.acceptMode) ? parsed.acceptMode : this.state.acceptMode,
        startedAt: parsed.startedAt ?? this.state.startedAt,
        completedAt: parsed.completedAt ?? this.state.completedAt
      }
    } catch (err) {
      this.emit('load-error', { message: 'bad wizard.json, resetting', error: err })
    }
  }

  /**
   * Persist current state. Atomic — write to .tmp then rename — so a power
   * cut never leaves a half-written wizard file.
   */
  async save () {
    const dir = dirname(this.storagePath)
    try { await mkdir(dir, { recursive: true }) } catch (_) {}
    const tmp = join(dir, basename(this.storagePath) + '.tmp')
    await writeFile(tmp, JSON.stringify(this.state, null, 2), 'utf8')
    try { await chmod(tmp, 0o600) } catch (_) {}
    await rename(tmp, this.storagePath)
  }

  /**
   * Whether the wizard has been completed. The HTTP layer uses this to
   * decide whether to render the wizard or the main dashboard.
   */
  isComplete () {
    return this.state.step === 'complete'
  }

  /**
   * Snapshot of current state for the UI. The payout address is public, so
   * it's returned as-is (no redaction needed).
   */
  snapshot () {
    return {
      step: this.state.step,
      relayName: this.state.relayName,
      payoutDestination: this.state.payoutDestination,
      hasPayout: !!this.state.payoutDestination,
      acceptMode: this.state.acceptMode,
      startedAt: this.state.startedAt,
      completedAt: this.state.completedAt,
      isComplete: this.isComplete()
    }
  }

  /**
   * Advance to (or jump to) a step. Jumping back is allowed so operators
   * can revisit prior steps without losing state.
   */
  goToStep ({ step }) {
    if (!VALID_STEPS.includes(step)) {
      return { ok: false, reason: 'unknown step: ' + step }
    }
    if (this.state.startedAt === null) this.state.startedAt = Date.now()
    this.state.step = step
    this.emit('step-changed', { step })
    return { ok: true, state: this.snapshot() }
  }

  /**
   * Set the relay's display name (used in the dashboard, /api/info, and as
   * a hint to federation peers). Length-bounded to avoid UI-layout issues.
   */
  setRelayName ({ relayName }) {
    if (typeof relayName !== 'string') return { ok: false, reason: 'relayName must be a string' }
    const trimmed = relayName.trim()
    if (trimmed.length === 0) return { ok: false, reason: 'relayName cannot be empty' }
    if (trimmed.length > 60) return { ok: false, reason: 'relayName max 60 chars' }
    this.state.relayName = trimmed
    return { ok: true, state: this.snapshot() }
  }

  /**
   * Set the operator's payout destination. OPTIONAL — pass null or
   * an empty string to clear/skip. The relay never holds funds; this feeds
   * `subsidy.payoutDestination`, which a coordinator pays out signed work
   * claims to. Validates as a lightning address, BOLT12 offer, or bitcoin
   * address.
   */
  setPayoutDestination ({ address } = {}) {
    if (address === null || address === undefined || (typeof address === 'string' && address.trim() === '')) {
      this.state.payoutDestination = null
      return { ok: true, state: this.snapshot() }
    }
    if (typeof address !== 'string') {
      return { ok: false, reason: 'address must be a string' }
    }
    const parsed = validatePayoutDestination(address)
    if (!parsed) {
      return { ok: false, reason: 'enter a valid lightning address, BOLT12 offer, or bitcoin address' }
    }
    this.state.payoutDestination = parsed.value
    return { ok: true, state: this.snapshot() }
  }

  /**
   * Set the accept-mode policy.
   */
  setAcceptMode ({ acceptMode }) {
    if (!VALID_ACCEPT_MODES.includes(acceptMode)) {
      return { ok: false, reason: 'acceptMode must be one of: ' + VALID_ACCEPT_MODES.join(', ') }
    }
    this.state.acceptMode = acceptMode
    return { ok: true, state: this.snapshot() }
  }

  /**
   * Mark the wizard complete. Caller should also call save() to persist.
   */
  complete () {
    this.state.step = 'complete'
    this.state.completedAt = Date.now()
    this.emit('completed', this.snapshot())
    return { ok: true, state: this.snapshot() }
  }

  /**
   * Returns the wizard's settings as a config object the relay node merges
   * into its live config (see RelayNode._applyWizardConfig). The payout
   * address is public, so no decryption is involved — this is synchronous.
   */
  toConfig () {
    return {
      name: this.state.relayName,
      acceptMode: this.state.acceptMode,
      subsidy: { payoutDestination: this.state.payoutDestination }
    }
  }

  /**
   * Reset the wizard. Mostly for debugging / reinstall scenarios.
   */
  reset () {
    this.state = {
      schemaVersion: SCHEMA_VERSION,
      step: 'welcome',
      relayName: generateDefaultName(),
      payoutDestination: null,
      acceptMode: 'review',
      startedAt: null,
      completedAt: null
    }
  }
}

/**
 * Picks a friendly default name, e.g. `silent-ember-4291`, that operators
 * can keep or change.
 */
function generateDefaultName () {
  const adjectives = ['silent', 'sturdy', 'glowing', 'patient', 'humble', 'eager', 'crisp', 'steady']
  const nouns = ['ember', 'beacon', 'anchor', 'lantern', 'spark', 'pillar', 'compass', 'haven']
  const adj = adjectives[Math.floor(Math.random() * adjectives.length)]
  const noun = nouns[Math.floor(Math.random() * nouns.length)]
  const suffix = String(Math.floor(Math.random() * 9000) + 1000)
  return `${adj}-${noun}-${suffix}`
}

export { VALID_STEPS, VALID_ACCEPT_MODES, SCHEMA_VERSION as WIZARD_SCHEMA_VERSION }