OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-service-config.test.js

1274 lines · javascript ref:
import test from 'brittle'
import http from 'http'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'
import { Federation } from 'p2p-hiverelay/core/federation.js'

const API_KEY = 'service-config-test-key'

function mockRelayNode () {
  return {
    running: true,
    config: {
      storage: null,
      enableServices: true,
      plugins: ['identity', 'vrf']
    },
    metrics: { getSummary () { return { uptime: 100 } } },
    seededApps: new Map(),
    appRegistry: {
      apps: new Map(),
      catalog () { return [] },
      catalogForBroadcast () { return [] }
    },
    getStats () { return { running: true, seededApps: 0, connections: 0 } },
    getHealthStatus () { return { healthy: true } },
    async stop () {},
    async start () {},
    async seedApp () { return { ok: true } },
    async unseedApp () {},
    verifyUnseedRequest () { return { ok: true } },
    broadcastUnseed () {},
    serviceRegistry: {
      services: new Map([
        ['identity', { status: 'running', running: true, capabilities: [] }]
      ])
    },
    router: null,
    reputation: null,
    networkDiscovery: null,
    seedingRegistry: null,
    relay: null,
    seeder: null,
    swarm: null,
    on () {},
    removeListener () {},
    emit () {}
  }
}

function request (port, method, path, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const opts = {
      hostname: '127.0.0.1',
      port,
      method,
      path,
      headers: {
        'Content-Type': 'application/json',
        ...headers
      }
    }
    const req = http.request(opts, (res) => {
      let data = ''
      res.on('data', (chunk) => { data += chunk })
      res.on('end', () => {
        let parsed
        try { parsed = JSON.parse(data) } catch (_) { parsed = data }
        resolve({ statusCode: res.statusCode, body: parsed, headers: res.headers })
      })
    })
    req.on('error', reject)
    if (body !== null && body !== undefined) req.write(JSON.stringify(body))
    req.end()
  })
}

async function makeServer (t, opts = {}) {
  const node = mockRelayNode()
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY })
  let persisted = null
  api._persistConfig = async () => {
    if (opts.persistError) throw opts.persistError
    persisted = api._getSafeConfig()
  }
  await api.start()
  const port = api.server.address().port

  t.teardown(async () => {
    await api.stop()
  })

  return {
    api,
    node,
    port,
    auth: { Authorization: 'Bearer ' + API_KEY },
    persisted: () => persisted
  }
}

test('service config api: catalog is authenticated and reports configured services', async (t) => {
  const { port, auth } = await makeServer(t)

  const denied = await request(port, 'GET', '/api/manage/services/available')
  t.is(denied.statusCode, 401)

  const res = await request(port, 'GET', '/api/manage/services/available', null, auth)
  t.is(res.statusCode, 200)
  t.is(res.body.enabled, true)
  t.alike(res.body.plugins, ['identity', 'vrf'])
  t.ok(res.body.available.includes('ai'))
  t.ok(res.body.available.includes('poker'))
  t.ok(res.body.available.includes('outboxlog'))
  t.ok(res.body.available.includes('notify'))
  t.alike(res.body.bundles.poker, ['poker', 'vrf', 'arbitration', 'zk'])
  t.alike(res.body.active, ['identity'])
})

test('service config api: saves builtins and expands poker bundle', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)

  const res = await request(port, 'POST', '/api/manage/services/config', {
    enabled: true,
    plugins: ['poker', 'ai', 'ai']
  }, auth)

  t.is(res.statusCode, 200)
  t.is(res.body.ok, true)
  t.is(res.body.restartRequired, true)
  t.alike(node.config.plugins, ['poker', 'vrf', 'arbitration', 'zk', 'ai'])
  t.is(node.config.enableServices, true)
  t.alike(res.body.config.plugins, node.config.plugins)
  t.alike(persisted().plugins, node.config.plugins)
  t.is(persisted().enableServices, true)
})

test('service config api: relaykernel profile rejects services opt-in', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.mode = 'relaykernel'
  node._operatingMode = 'relaykernel'
  node.config.productProfile = 'relaykernel'
  node.config.enableServices = false
  node.config.plugins = []

  const res = await request(port, 'POST', '/api/manage/services/config', {
    enabled: true,
    plugins: ['ai']
  }, auth)

  t.is(res.statusCode, 409)
  t.is(res.body.errorCode, 'relaykernel-services-locked')
  t.is(res.body.config.enabled, false)
  t.is(res.body.config.locked, true)
  t.alike(res.body.config.plugins, [])
  t.is(node.config.enableServices, false)
  t.alike(node.config.plugins, [])
  t.is(persisted(), null)
})

test('service config api: public-t1 profile rejects services opt-in', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.mode = 'public-t1-gateway'
  node._operatingMode = 'public-t1-gateway'
  node.config.productProfile = 'public-t1-gateway'
  node.config.enableServices = false
  node.config.plugins = []

  const res = await request(port, 'POST', '/api/manage/services/config', {
    enabled: true,
    plugins: ['ai']
  }, auth)
  t.is(res.statusCode, 409)
  t.is(res.body.errorCode, 'public-t1-gateway-services-locked')
  t.is(node.config.enableServices, false)
  t.alike(node.config.plugins, [])
  t.is(persisted(), null)
})

test('poker service http api is delegated when poker provider is running', async (t) => {
  const { node, port } = await makeServer(t)
  node.serviceRegistry.services.set('poker', {
    status: 'running',
    running: true,
    provider: {
      listTables () {
        return [{ tableKey: 'table-1', length: 2, writers: ['a'.repeat(64), 'b'.repeat(64)] }]
      }
    }
  })

  const res = await request(port, 'GET', '/api/poker/tables')
  t.is(res.statusCode, 200)
  t.is(res.body.tables.length, 1)
  t.is(res.body.tables[0].tableKey, 'table-1')
})

test('poker table api keeps app-facing CORS separate from management usage telemetry', async (t) => {
  const { port } = await makeServer(t)

  const tables = await request(port, 'OPTIONS', '/api/poker/tables', null, {
    Origin: 'https://app.example'
  })
  t.is(tables.statusCode, 204)
  t.is(tables.headers['access-control-allow-origin'], '*')

  const usage = await request(port, 'OPTIONS', '/api/poker/usage', null, {
    Origin: 'https://app.example'
  })
  t.is(usage.statusCode, 403)
})

test('service management api: disable persists configured plugins before unregistering', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  const unregistered = []
  node.config.plugins = ['identity', 'vrf']
  node.serviceRegistry = {
    services: new Map([
      ['identity', { status: 'running', running: true, capabilities: [] }],
      ['vrf', { status: 'running', running: true, capabilities: [] }]
    ]),
    async unregister (name) {
      unregistered.push(name)
      this.services.delete(name)
      return true
    }
  }

  const res = await request(port, 'POST', '/api/manage/services', {
    action: 'disable',
    service: 'identity'
  }, auth)

  t.is(res.statusCode, 200)
  t.is(res.body.ok, true)
  t.is(res.body.persistent, true)
  t.alike(unregistered, ['identity'])
  t.alike(node.config.plugins, ['vrf'])
  t.alike(persisted().plugins, ['vrf'])
  t.is(node.config.enableServices, true)
  t.absent(node.serviceRegistry.services.has('identity'))
})

test('service management api: disable rolls back configured plugins when persistence fails', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('readonly config') })
  const unregistered = []
  node.config.plugins = ['identity', 'vrf']
  node.serviceRegistry = {
    services: new Map([
      ['identity', { status: 'running', running: true, capabilities: [] }]
    ]),
    async unregister (name) {
      unregistered.push(name)
      this.services.delete(name)
      return true
    }
  }

  const res = await request(port, 'POST', '/api/manage/services', {
    action: 'disable',
    service: 'identity'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.alike(node.config.plugins, ['identity', 'vrf'])
  t.alike(unregistered, [], 'live service is not unregistered before durable config save')
  t.ok(node.serviceRegistry.services.has('identity'))
  t.is(persisted(), null)
})

test('service management api: disable rejects services required by configured bundles', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  const unregistered = []
  node.config.plugins = ['poker', 'vrf', 'arbitration', 'zk']
  node.serviceRegistry = {
    services: new Map([
      ['vrf', { status: 'running', running: true, capabilities: [] }]
    ]),
    async unregister (name) {
      unregistered.push(name)
      this.services.delete(name)
      return true
    }
  }

  const res = await request(port, 'POST', '/api/manage/services', {
    action: 'disable',
    service: 'vrf'
  }, auth)

  t.is(res.statusCode, 409)
  t.ok(res.body.error.includes("Service 'vrf' is required by bundle: poker"))
  t.alike(res.body.bundles, ['poker'])
  t.alike(node.config.plugins, ['poker', 'vrf', 'arbitration', 'zk'])
  t.alike(unregistered, [])
  t.ok(node.serviceRegistry.services.has('vrf'))
  t.is(persisted(), null)
})

test('service config api: persistence failure is reported before success', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('disk full') })

  const res = await request(port, 'POST', '/api/manage/services/config', {
    enabled: true,
    plugins: ['ai']
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.absent(res.body.ok)
  t.is(persisted(), null)
  t.alike(node.config.plugins, ['identity', 'vrf'])
  t.is(node.config.enableServices, true)
})

test('wallet destination api: persistence failure is not reported as a bad wallet', async (t) => {
  const { node, port, auth } = await makeServer(t, { persistError: new Error('readonly volume') })

  const res = await request(port, 'POST', '/api/subsidy/destination', {
    destination: 'Satoshi@GetAlby.com'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.absent(node.config.subsidy)
})

test('wallet destination api: wizard persistence failure rolls back config before success', async (t) => {
  const { api, node, port, auth, persisted } = await makeServer(t)
  node.config.subsidy = { payoutDestination: 'old@example.com', enabled: true }
  const wizard = makeWizard({
    relayName: 'Umbrel relay',
    acceptMode: 'review',
    payoutDestination: 'old@example.com'
  })
  const oldWizardState = { ...wizard.state }
  api._wizard = wizard
  wizard.save = async () => { throw new Error('wizard disk full') }

  const res = await request(port, 'POST', '/api/subsidy/destination', {
    destination: 'operator@example.com'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.alike(node.config.subsidy, { payoutDestination: 'old@example.com', enabled: true })
  t.alike(wizard.state, oldWizardState)
  t.is(persisted(), null)
})

test('wallet destination api: config persistence failure restores saved wizard state', async (t) => {
  const { api, node, port, auth } = await makeServer(t, { persistError: new Error('readonly config') })
  node.config.subsidy = { payoutDestination: 'old@example.com', enabled: true }
  const wizard = makeWizard({
    relayName: 'Umbrel relay',
    acceptMode: 'review',
    payoutDestination: 'old@example.com'
  })
  const savedStates = []
  api._wizard = wizard
  wizard.save = async () => {
    savedStates.push({ ...wizard.state })
  }

  const res = await request(port, 'POST', '/api/subsidy/destination', {
    destination: 'operator@example.com'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.alike(node.config.subsidy, { payoutDestination: 'old@example.com', enabled: true })
  t.is(wizard.state.payoutDestination, 'old@example.com')
  t.is(savedStates.length, 2)
  t.is(savedStates[0].payoutDestination, 'operator@example.com')
  t.is(savedStates[1].payoutDestination, 'old@example.com')
})

test('wallet destination api: live subsidy persistence failure rolls back config and wizard files', async (t) => {
  const { api, node, port, auth, persisted } = await makeServer(t)
  node.config.subsidy = { payoutDestination: 'old@example.com', enabled: true }
  const wizard = makeWizard({
    relayName: 'Umbrel relay',
    acceptMode: 'review',
    payoutDestination: 'old@example.com'
  })
  const savedStates = []
  const liveWrites = []
  const persistErrors = []
  api._wizard = wizard
  api.on('subsidy-persist-error', (info) => persistErrors.push(info.message))
  wizard.save = async () => {
    savedStates.push({ ...wizard.state })
  }
  node.subsidyAccrual = {
    async setPayoutDestination (value) {
      liveWrites.push(value)
      throw new Error('subsidy store readonly')
    }
  }

  const res = await request(port, 'POST', '/api/subsidy/destination', {
    destination: 'operator@example.com'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.alike(liveWrites, ['operator@example.com'])
  t.alike(node.config.subsidy, { payoutDestination: 'old@example.com', enabled: true })
  t.is(wizard.state.payoutDestination, 'old@example.com')
  t.is(savedStates.length, 2)
  t.is(savedStates[0].payoutDestination, 'operator@example.com')
  t.is(savedStates[1].payoutDestination, 'old@example.com')
  t.is(persisted().subsidy.payoutDestination, 'old@example.com')
  t.alike(persistErrors, ['subsidy store readonly'])
})

test('service config api: oversized JSON body returns 413 without resetting the client', async (t) => {
  const { port, auth } = await makeServer(t)

  const res = await request(port, 'POST', '/api/manage/services/config', {
    enabled: true,
    plugins: ['ai'],
    padding: 'x'.repeat(70000)
  }, auth)

  t.is(res.statusCode, 413)
  t.is(res.body.error, 'Request body too large')
  t.is(res.headers.connection, 'close')

  const health = await request(port, 'GET', '/health')
  t.is(health.statusCode, 200)
})

test('service config api: rejects top-level non-object JSON bodies before mutation', async (t) => {
  const { node, port, auth } = await makeServer(t)

  const res = await request(port, 'POST', '/api/manage/services/config', [], auth)

  t.is(res.statusCode, 400)
  t.is(res.body.error, 'JSON body must be an object')
  t.alike(node.config.plugins, ['identity', 'vrf'])
  t.is(node.config.enableServices, true)
})

test('service config api: disables services and rejects arbitrary plugin paths', async (t) => {
  const { node, port, auth } = await makeServer(t)

  const disabled = await request(port, 'POST', '/api/manage/services/config', {
    enabled: false,
    plugins: []
  }, auth)
  t.is(disabled.statusCode, 200)
  t.is(node.config.enableServices, false)
  t.alike(node.config.plugins, [])

  const before = node.config.plugins.slice()
  const rejected = await request(port, 'POST', '/api/manage/services/config', {
    enabled: true,
    plugins: ['../../evil.js']
  }, auth)
  t.is(rejected.statusCode, 400)
  t.ok(rejected.body.error.includes('unknown service plugin'))
  t.alike(node.config.plugins, before)
  t.is(node.config.enableServices, false)
})

test('config management api: rejects malformed integer strings and rolls back earlier fields', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.config.maxConnections = 7
  node.config.maxStorageBytes = 2 * 1048576

  const partial = await request(port, 'POST', '/api/manage/config', {
    maxConnections: '12',
    maxStorageBytes: '1e9'
  }, auth)

  t.is(partial.statusCode, 400)
  t.is(partial.body.error, 'maxStorageBytes must be a valid integer')
  t.is(node.config.maxConnections, 7, 'earlier valid field was rolled back')
  t.is(node.config.maxStorageBytes, 2 * 1048576, 'invalid field did not mutate')
  t.is(persisted(), null, 'invalid config was not persisted')

  const decimal = await request(port, 'POST', '/api/manage/config', {
    maxConnections: '12.5'
  }, auth)
  t.is(decimal.statusCode, 400)
  t.is(decimal.body.error, 'maxConnections must be a valid integer')
  t.is(node.config.maxConnections, 7, 'decimal value did not mutate config')

  const valid = await request(port, 'POST', '/api/manage/config', {
    maxConnections: '12',
    maxStorageBytes: String(3 * 1048576)
  }, auth)
  t.is(valid.statusCode, 200)
  t.is(node.config.maxConnections, 12, 'plain decimal integer string is accepted')
  t.is(node.config.maxStorageBytes, 3 * 1048576, 'valid integer string is accepted')
  t.is(persisted().maxConnections, 12, 'valid integer config persisted')
})

test('config management api: rejects malformed decimal strings and rolls back earlier fields', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.config.maxConnections = 7
  node.config.maxRelayBandwidthMbps = 25

  const exponential = await request(port, 'POST', '/api/manage/config', {
    maxConnections: '12',
    maxRelayBandwidthMbps: '1e3'
  }, auth)
  t.is(exponential.statusCode, 400)
  t.is(exponential.body.error, 'maxRelayBandwidthMbps must be a valid number')
  t.is(node.config.maxConnections, 7, 'earlier integer field was rolled back')
  t.is(node.config.maxRelayBandwidthMbps, 25, 'malformed bandwidth did not mutate')
  t.is(persisted(), null, 'malformed decimal config was not persisted')

  const blank = await request(port, 'POST', '/api/manage/config', {
    maxRelayBandwidthMbps: '   '
  }, auth)
  t.is(blank.statusCode, 400)
  t.is(blank.body.error, 'maxRelayBandwidthMbps must be a valid number')
  t.is(node.config.maxRelayBandwidthMbps, 25, 'blank string did not mutate bandwidth')

  const valid = await request(port, 'POST', '/api/manage/config', {
    maxRelayBandwidthMbps: '12.5'
  }, auth)
  t.is(valid.statusCode, 200)
  t.is(node.config.maxRelayBandwidthMbps, 12.5, 'plain decimal string is accepted')
  t.is(persisted().maxRelayBandwidthMbps, 12.5, 'plain decimal config persisted')
})

test('config management api: rejects malformed boolean fields before mutation', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.config.maxConnections = 7
  node.config.registryAutoAccept = false

  const malformed = await request(port, 'POST', '/api/manage/config', {
    maxConnections: '12',
    registryAutoAccept: 'false'
  }, auth)

  t.is(malformed.statusCode, 400)
  t.is(malformed.body.error, 'registryAutoAccept must be a boolean')
  t.is(node.config.maxConnections, 7, 'earlier integer field was rolled back')
  t.is(node.config.registryAutoAccept, false, 'malformed boolean did not mutate')
  t.is(persisted(), null, 'invalid config was not persisted')
})

test('catalog mode api: persists accept mode and removes legacy auto-accept alias', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.config.registryAutoAccept = true

  const res = await request(port, 'POST', '/api/manage/catalog/mode', {
    mode: 'open'
  }, auth)

  t.is(res.statusCode, 200)
  t.is(res.body.mode, 'open')
  t.is(node.config.acceptMode, 'open')
  t.absent(node.config.registryAutoAccept)
  t.is(persisted().acceptMode, 'open')
  t.absent(persisted().registryAutoAccept)
})

test('catalog mode api: persistence failure rolls back accept mode', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('readonly config') })
  node.config.acceptMode = 'review'
  node.config.registryAutoAccept = false

  const res = await request(port, 'POST', '/api/manage/catalog/mode', {
    mode: 'open'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.is(node.config.acceptMode, 'review')
  t.is(node.config.registryAutoAccept, false)
  t.is(persisted(), null)
})

test('legacy auto-accept api: persists and rolls back on failure', async (t) => {
  {
    const { node, port, auth, persisted } = await makeServer(t)
    node.config.acceptMode = 'review'

    const res = await request(port, 'POST', '/registry/auto-accept', {
      enabled: true
    }, auth)

    t.is(res.statusCode, 200)
    t.is(res.body.autoAccept, true)
    t.absent(node.config.acceptMode)
    t.is(node.config.registryAutoAccept, true)
    t.absent(persisted().acceptMode)
    t.is(persisted().registryAutoAccept, true)
  }

  {
    const { node, port, auth, persisted } = await makeServer(t)
    node.config.acceptMode = 'review'
    node.config.registryAutoAccept = false

    const res = await request(port, 'POST', '/registry/auto-accept', {
      enabled: 'false'
    }, auth)

    t.is(res.statusCode, 400)
    t.is(res.body.error, 'enabled must be a boolean')
    t.is(node.config.acceptMode, 'review')
    t.is(node.config.registryAutoAccept, false)
    t.is(persisted(), null)
  }

  {
    const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('readonly config') })
    node.config.acceptMode = 'review'
    node.config.registryAutoAccept = false

    const res = await request(port, 'POST', '/registry/auto-accept', {
      enabled: true
    }, auth)

    t.is(res.statusCode, 500)
    t.ok(res.body.error.startsWith('persist-failed: '))
    t.is(node.config.acceptMode, 'review')
    t.is(node.config.registryAutoAccept, false)
    t.is(persisted(), null)
  }
})

test('catalog allowlist api: normalizes, de-duplicates, and persists publisher keys', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  const upper = 'A'.repeat(64)
  const lower = 'b'.repeat(64)

  const res = await request(port, 'POST', '/api/manage/catalog/allowlist', {
    allowlist: [upper, lower, upper.toLowerCase()]
  }, auth)

  t.is(res.statusCode, 200)
  t.alike(res.body.allowlist, [upper.toLowerCase(), lower])
  t.alike(node.config.acceptAllowlist, [upper.toLowerCase(), lower])
  t.alike(persisted().acceptAllowlist, [upper.toLowerCase(), lower])
})

test('catalog allowlist api: rejects malformed entries without mutating state', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.config.acceptAllowlist = ['c'.repeat(64)]

  const res = await request(port, 'POST', '/api/manage/catalog/allowlist', {
    allowlist: ['not-a-pubkey']
  }, auth)

  t.is(res.statusCode, 400)
  t.ok(res.body.error.includes('64-char hex'))
  t.alike(node.config.acceptAllowlist, ['c'.repeat(64)])
  t.is(persisted(), null)
})

test('catalog allowlist api: persistence failure rolls back publisher keys', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('readonly config') })
  const previous = ['d'.repeat(64)]
  node.config.acceptAllowlist = previous.slice()

  const res = await request(port, 'POST', '/api/manage/catalog/allowlist', {
    allowlist: ['e'.repeat(64)]
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.alike(node.config.acceptAllowlist, previous)
  t.is(persisted(), null)
})

test('wizard complete api: persists applied accept mode before success', async (t) => {
  const node = mockRelayNode()
  const auth = { Authorization: 'Bearer ' + API_KEY }
  node.config.name = 'old-relay'
  node.config.registryAutoAccept = true
  node._applyWizardConfig = function (cfg) {
    this.config.name = cfg.name
    this.config.acceptMode = cfg.acceptMode
    delete this.config.registryAutoAccept
    this.config.subsidy = { ...this.config.subsidy, ...cfg.subsidy }
  }

  const wizard = makeWizard({
    relayName: 'Umbrel relay',
    acceptMode: 'open',
    payoutDestination: 'operator@example.com'
  })
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY })
  let saved = false
  let persistedConfig = null
  api._wizard = wizard
  wizard.save = async () => { saved = true }
  api._persistConfig = async () => { persistedConfig = api._getSafeConfig() }
  await api.start()
  const wizardPort = api.server.address().port
  t.teardown(async () => {
    await api.stop()
  })

  const res = await request(wizardPort, 'POST', '/api/wizard/complete', {}, auth)

  t.is(res.statusCode, 200)
  t.is(saved, true)
  t.is(node.config.name, 'Umbrel relay')
  t.is(node.config.acceptMode, 'open')
  t.absent(node.config.registryAutoAccept)
  t.is(persistedConfig.name, 'Umbrel relay')
  t.is(persistedConfig.acceptMode, 'open')
  t.absent(persistedConfig.registryAutoAccept)
  t.is(persistedConfig.subsidy.payoutDestination, 'operator@example.com')
})

test('wizard complete api: persistence failure rolls back applied config and wizard state', async (t) => {
  const node = mockRelayNode()
  const auth = { Authorization: 'Bearer ' + API_KEY }
  node.config.name = 'old-relay'
  node.config.acceptMode = 'review'
  node.config.registryAutoAccept = false
  node.config.subsidy = { payoutDestination: null }
  node._applyWizardConfig = function (cfg) {
    this.config.name = cfg.name
    this.config.acceptMode = cfg.acceptMode
    delete this.config.registryAutoAccept
    this.config.subsidy = { ...this.config.subsidy, ...cfg.subsidy }
  }

  const wizard = makeWizard({
    relayName: 'Umbrel relay',
    acceptMode: 'open',
    payoutDestination: 'operator@example.com'
  })
  const oldState = { ...wizard.state }
  const api = new RelayAPI(node, { apiPort: 0, apiHost: '127.0.0.1', apiKey: API_KEY })
  api._wizard = wizard
  api._persistConfig = async () => { throw new Error('readonly config') }
  await api.start()
  const wizardPort = api.server.address().port
  t.teardown(async () => {
    await api.stop()
  })

  const res = await request(wizardPort, 'POST', '/api/wizard/complete', {}, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(node.config.name, 'old-relay')
  t.is(node.config.acceptMode, 'review')
  t.is(node.config.registryAutoAccept, false)
  t.is(node.config.subsidy.payoutDestination, null)
  t.alike(wizard.state, oldState)
})

test('mode management api: persists applied mode before reporting success', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.mode = 'standard'
  node._operatingMode = 'standard'
  node.applyMode = async function (mode, overrides = {}) {
    this.mode = mode
    this._operatingMode = mode
    this.config = {
      ...this.config,
      mode,
      maxConnections: overrides.maxConnections
    }
    return this.config
  }

  const res = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxConnections: 12
  }, auth)

  t.is(res.statusCode, 200)
  t.is(res.body.ok, true)
  t.is(node.mode, 'homehive')
  t.is(node.config.maxConnections, 12)
  t.is(persisted().mode, 'homehive')
  t.is(persisted().maxConnections, 12)
})

test('mode management api: rejects malformed integer overrides before applying mode', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.mode = 'standard'
  node._operatingMode = 'standard'
  let applyCalls = 0
  node.applyMode = async function (mode, overrides = {}) {
    applyCalls++
    this.mode = mode
    this._operatingMode = mode
    this.config = { ...this.config, mode, ...overrides }
    return this.config
  }

  const malformed = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxConnections: '12abc'
  }, auth)
  t.is(malformed.statusCode, 400)
  t.is(malformed.body.error, 'maxConnections must be a valid integer')
  t.is(applyCalls, 0, 'mode was not applied after malformed maxConnections')
  t.is(node.mode, 'standard')
  t.is(persisted(), null)

  const exponential = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxStorageBytes: '1e9'
  }, auth)
  t.is(exponential.statusCode, 400)
  t.is(exponential.body.error, 'maxStorageBytes must be a valid integer')
  t.is(applyCalls, 0, 'mode was not applied after exponential maxStorageBytes')
  t.is(node.mode, 'standard')

  const booleanLike = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    registryAutoAccept: 'false'
  }, auth)
  t.is(booleanLike.statusCode, 400)
  t.is(booleanLike.body.error, 'registryAutoAccept must be a boolean')
  t.is(applyCalls, 0, 'mode was not applied after malformed boolean')
  t.is(node.mode, 'standard')
})

test('mode management api: rejects malformed bandwidth override before applying mode', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t)
  node.mode = 'standard'
  node._operatingMode = 'standard'
  let applyCalls = 0
  node.applyMode = async function (mode, overrides = {}) {
    applyCalls++
    this.mode = mode
    this._operatingMode = mode
    this.config = { ...this.config, mode, ...overrides }
    return this.config
  }

  const blank = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxRelayBandwidthMbps: '   '
  }, auth)
  t.is(blank.statusCode, 400)
  t.is(blank.body.error, 'maxRelayBandwidthMbps must be a valid number')
  t.is(applyCalls, 0, 'mode was not applied after blank bandwidth')
  t.is(node.mode, 'standard')
  t.is(persisted(), null)

  const exponential = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxRelayBandwidthMbps: '1e3'
  }, auth)
  t.is(exponential.statusCode, 400)
  t.is(exponential.body.error, 'maxRelayBandwidthMbps must be a valid number')
  t.is(applyCalls, 0, 'mode was not applied after exponential bandwidth')
  t.is(node.mode, 'standard')

  const valid = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxRelayBandwidthMbps: '12.5'
  }, auth)
  t.is(valid.statusCode, 200)
  t.is(node.mode, 'homehive')
  t.is(node.config.maxRelayBandwidthMbps, 12.5, 'plain decimal bandwidth is applied')
  t.is(persisted().maxRelayBandwidthMbps, 12.5, 'plain decimal bandwidth is persisted')
})

test('mode management api: persistence failure rolls back applied mode', async (t) => {
  const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('readonly config') })
  const previousConfig = node.config
  node.mode = 'standard'
  node._operatingMode = 'standard'
  let syncCalls = 0
  node._syncAccessControl = async () => { syncCalls++ }
  node.applyMode = async function (mode, overrides = {}) {
    this.mode = mode
    this._operatingMode = mode
    this.config = {
      ...this.config,
      mode,
      maxConnections: overrides.maxConnections
    }
    return this.config
  }

  const res = await request(port, 'POST', '/api/manage/mode', {
    mode: 'homehive',
    maxConnections: 12
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  t.is(node.mode, 'standard')
  t.is(node._operatingMode, 'standard')
  t.is(node.config, previousConfig)
  t.absent(node.config.maxConnections)
  t.is(syncCalls, 1)
  t.is(persisted(), null)
})

test('transport management api: persists before success and rolls back on failure', async (t) => {
  {
    const { node, port, auth, persisted } = await makeServer(t)
    delete node.config.transports

    const res = await request(port, 'POST', '/api/manage/transport', {
      transport: 'tor',
      enabled: 'false'
    }, auth)

    t.is(res.statusCode, 400)
    t.is(res.body.error, 'enabled must be a boolean')
    t.absent(node.config.transports)
    t.is(persisted(), null)
  }

  {
    const { node, port, auth, persisted } = await makeServer(t)
    node.config.transports = { udp: true }

    const res = await request(port, 'POST', '/api/manage/transport', {
      transport: 'tor',
      enabled: true
    }, auth)

    t.is(res.statusCode, 200)
    t.is(res.body.ok, true)
    t.is(node.config.transports.tor, true)
    t.is(persisted().transports.tor, true)
  }

  {
    const { node, port, auth, persisted } = await makeServer(t, { persistError: new Error('readonly config') })
    node.config.transports = { udp: true }

    const res = await request(port, 'POST', '/api/manage/transport', {
      transport: 'tor',
      enabled: true
    }, auth)

    t.is(res.statusCode, 500)
    t.ok(res.body.error.startsWith('persist-failed: '))
    t.is(res.body.errorCode, 'persist-failed')
    t.alike(node.config.transports, { udp: true })
    t.is(persisted(), null)
  }
})

test('pairing management api: rejects malformed timeout before enabling pairing', async (t) => {
  const { node, port, auth } = await makeServer(t)
  let enabled = 0
  node.mode = 'private'
  node.accessControl = {
    isPairing: false,
    _pairingState: null,
    disablePairing () {}
  }
  node.enablePairing = (opts = {}) => {
    enabled++
    return { token: 'a'.repeat(32), expiresAt: Date.now() + opts.timeoutMs }
  }

  const malformed = await request(port, 'POST', '/api/manage/pairing', {
    action: 'start',
    timeoutMs: '1e3'
  }, auth)
  t.is(malformed.statusCode, 400)
  t.is(malformed.body.error, 'timeoutMs must be a valid integer')
  t.is(enabled, 0, 'malformed timeout does not enable pairing')

  const zero = await request(port, 'POST', '/api/manage/pairing', {
    action: 'start',
    timeoutMs: 0
  }, auth)
  t.is(zero.statusCode, 400)
  t.is(zero.body.error, 'timeoutMs must be between 10000 and 1800000')
  t.is(enabled, 0, 'out-of-range timeout does not enable pairing')

  const valid = await request(port, 'POST', '/api/manage/pairing', {
    action: 'start',
    timeoutMs: '10000'
  }, auth)
  t.is(valid.statusCode, 200)
  t.is(valid.body.ok, true)
  t.is(enabled, 1, 'plain integer string enables pairing')
})

test('device management api: add/list/remove canonicalizes keys and bounds names', async (t) => {
  const { node, port, auth } = await makeServer(t)
  const devices = new Map()
  node.accessControl = {}
  node.listDevices = () => Array.from(devices.values())
  node.addDevice = async (pubkey, name) => {
    devices.set(pubkey, { pubkey, name, pairedAt: 1, lastSeen: null })
  }
  node.removeDevice = async (pubkey) => {
    if (!devices.has(pubkey)) throw new Error('Device not in allowlist')
    devices.delete(pubkey)
  }

  const upperPubkey = 'A'.repeat(64)
  const res = await request(port, 'POST', '/api/manage/devices', {
    action: 'add',
    pubkey: upperPubkey,
    name: '  Operator phone  '
  }, auth)

  t.is(res.statusCode, 200)
  t.is(res.body.pubkey, 'a'.repeat(64))
  t.is(res.body.name, 'Operator phone')
  t.is(devices.get('a'.repeat(64)).name, 'Operator phone')

  const listed = await request(port, 'GET', '/api/manage/devices', null, auth)
  t.is(listed.statusCode, 200)
  t.is(listed.body.count, 1)
  t.is(listed.body.devices[0].pubkey, 'a'.repeat(64))

  const removed = await request(port, 'POST', '/api/manage/devices', {
    action: 'remove',
    pubkey: upperPubkey
  }, auth)

  t.is(removed.statusCode, 200)
  t.is(removed.body.pubkey, 'a'.repeat(64))
  t.is(devices.size, 0)
})

test('device management api: rejects invalid names and known access-control errors as bad requests', async (t) => {
  const { node, port, auth } = await makeServer(t)
  node.accessControl = {}
  node.listDevices = () => []
  node.removeDevice = async () => { throw new Error('Device not in allowlist') }

  const invalidName = await request(port, 'POST', '/api/manage/devices', {
    action: 'add',
    pubkey: 'a'.repeat(64),
    name: { label: 'phone' }
  }, auth)
  t.is(invalidName.statusCode, 400)
  t.is(invalidName.body.error, 'name must be a string')

  const longName = await request(port, 'POST', '/api/manage/devices', {
    action: 'add',
    pubkey: 'a'.repeat(64),
    name: 'x'.repeat(81)
  }, auth)
  t.is(longName.statusCode, 400)
  t.is(longName.body.error, 'name exceeds max length (80)')

  node.addDevice = async () => { throw new Error('Maximum devices reached (50)') }
  const maxDevices = await request(port, 'POST', '/api/manage/devices', {
    action: 'add',
    pubkey: 'a'.repeat(64),
    name: 'phone'
  }, auth)
  t.is(maxDevices.statusCode, 400)
  t.is(maxDevices.body.error, 'Maximum devices reached (50)')

  const missing = await request(port, 'POST', '/api/manage/devices', {
    action: 'remove',
    pubkey: 'b'.repeat(64)
  }, auth)
  t.is(missing.statusCode, 400)
  t.is(missing.body.error, 'Device not in allowlist')
})

test('device management api: persistence failures return persist-failed response', async (t) => {
  const { api, node, port, auth } = await makeServer(t)
  node.accessControl = {}
  node.listDevices = () => []
  node.addDevice = async () => { throw new Error('disk full') }
  node.removeDevice = async () => { throw new Error('readonly allowlist') }

  const errors = []
  api.on('device-persist-error', (info) => errors.push(info.message))

  const add = await request(port, 'POST', '/api/manage/devices', {
    action: 'add',
    pubkey: 'a'.repeat(64),
    name: 'phone'
  }, auth)
  t.is(add.statusCode, 500)
  t.ok(add.body.error.startsWith('persist-failed: '))
  t.is(add.body.errorCode, 'persist-failed')

  const remove = await request(port, 'POST', '/api/manage/devices', {
    action: 'remove',
    pubkey: 'a'.repeat(64)
  }, auth)
  t.is(remove.statusCode, 500)
  t.ok(remove.body.error.startsWith('persist-failed: '))
  t.is(remove.body.errorCode, 'persist-failed')
  t.alike(errors, ['disk full', 'readonly allowlist'])
})

test('federation management api: persists every mutation before reporting success', async (t) => {
  const { node, port, auth } = await makeServer(t)
  const federation = new Federation({ node })
  node.federation = federation

  let saveCalls = 0
  federation.save = async (opts = {}) => {
    saveCalls++
    t.is(opts.throwOnError, true, 'management save waits for durable persistence')
  }

  const followed = await request(port, 'POST', '/api/manage/federation/follow', {
    url: 'http://relay-a.example'
  }, auth)
  t.is(followed.statusCode, 200)
  t.is(followed.body.mode, 'follow')

  const mirrored = await request(port, 'POST', '/api/manage/federation/mirror', {
    url: 'https://relay-b.example',
    pubkey: 'b'.repeat(64)
  }, auth)
  t.is(mirrored.statusCode, 200)
  t.is(mirrored.body.mode, 'mirror')

  const republished = await request(port, 'POST', '/api/manage/federation/republish', {
    appKey: 'a'.repeat(64),
    sourceUrl: 'https://source.example',
    sourcePubkey: 'c'.repeat(64),
    channel: 'stable',
    note: 'operator curated'
  }, auth)
  t.is(republished.statusCode, 200)
  t.is(republished.body.appKey, 'a'.repeat(64))

  const unfollowed = await request(port, 'POST', '/api/manage/federation/unfollow', {
    url: 'http://relay-a.example'
  }, auth)
  t.is(unfollowed.statusCode, 200)
  t.is(unfollowed.body.removed, true)

  const unrepublished = await request(port, 'POST', '/api/manage/federation/unrepublish', {
    appKey: 'a'.repeat(64)
  }, auth)
  t.is(unrepublished.statusCode, 200)
  t.is(unrepublished.body.removed, true)

  const snap = federation.snapshot()
  t.alike(snap.followed, [])
  t.is(snap.mirrored.length, 1)
  t.is(snap.mirrored[0].url, 'https://relay-b.example')
  t.alike(snap.republished, [])
  t.is(saveCalls, 5)
})

test('federation management api: persistence failure rolls back in-memory state', async (t) => {
  const { node, port, auth } = await makeServer(t)
  const federation = new Federation({ node })
  federation.follow('http://existing.example', { persist: false })
  node.federation = federation

  federation.save = async () => {
    throw new Error('readonly federation volume')
  }

  const res = await request(port, 'POST', '/api/manage/federation/follow', {
    url: 'http://new.example'
  }, auth)

  t.is(res.statusCode, 500)
  t.ok(res.body.error.startsWith('persist-failed: '))
  t.is(res.body.errorCode, 'persist-failed')
  const snap = federation.snapshot()
  t.is(snap.followed.length, 1)
  t.is(snap.followed[0].url, 'http://existing.example')
})

test('federation management api: validation failure rolls back and does not persist', async (t) => {
  const { node, port, auth } = await makeServer(t)
  const federation = new Federation({ node })
  node.federation = federation

  let saveCalls = 0
  federation.save = async () => { saveCalls++ }

  const res = await request(port, 'POST', '/api/manage/federation/republish', {
    appKey: 'd'.repeat(64),
    sourceUrl: 'javascript:alert(1)'
  }, auth)

  t.is(res.statusCode, 400)
  t.ok(res.body.error.startsWith('Federation:'))
  t.alike(federation.snapshot().republished, [])
  t.is(saveCalls, 0)
})

test('custody witness api uses parsed body once and validates intent ids at the route boundary', async (t) => {
  const { node, port, auth } = await makeServer(t)
  const calls = []
  const intentId = 'a'.repeat(64)
  node.swarm = { keyPair: { publicKey: Buffer.alloc(32), secretKey: Buffer.alloc(64) } }
  node.seedingRegistry = {
    async recordCustodyExpiryWitness (payload, keyPair) {
      calls.push({ payload, keyPair })
      return {
        ok: true,
        type: 'custody-expiry-witness',
        intentId: payload.intentId,
        nonServingProofHash: payload.nonServingProofHash
      }
    }
  }

  const invalid = await request(port, 'POST', '/api/custody/not-hex/witness', {
    nonServingProofHash: 'b'.repeat(64)
  }, auth)
  t.is(invalid.statusCode, 400)
  t.is(invalid.body.error, 'intentId must be 64 hex characters')
  t.is(calls.length, 0, 'invalid route id does not reach registry')

  const res = await request(port, 'POST', `/api/custody/${intentId}/witness`, {
    nonServingProofHash: 'b'.repeat(64)
  }, auth)
  t.is(res.statusCode, 200)
  t.is(res.body.ok, true)
  t.is(res.body.intentId, intentId)
  t.is(res.body.nonServingProofHash, 'b'.repeat(64))
  t.is(calls.length, 1, 'registry called exactly once')
  t.alike(calls[0].payload, {
    nonServingProofHash: 'b'.repeat(64),
    intentId
  })
  t.is(calls[0].keyPair, node.swarm.keyPair)
})

function makeWizard ({ relayName, acceptMode, payoutDestination }) {
  return {
    state: {
      schemaVersion: 3,
      step: 'accept_mode',
      relayName,
      payoutDestination,
      acceptMode,
      startedAt: 1,
      completedAt: null
    },
    snapshot () {
      return {
        ...this.state,
        hasPayout: !!this.state.payoutDestination,
        isComplete: this.state.step === 'complete'
      }
    },
    setPayoutDestination ({ address } = {}) {
      this.state.payoutDestination = address || null
      return { ok: true, state: this.snapshot() }
    },
    complete () {
      this.state.step = 'complete'
      this.state.completedAt = 2
      return { ok: true, state: this.snapshot() }
    },
    toConfig () {
      return {
        name: this.state.relayName,
        acceptMode: this.state.acceptMode,
        subsidy: { payoutDestination: this.state.payoutDestination }
      }
    },
    async save () {}
  }
}