OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / core / relay-node / api-device-pairing.js

256 lines · javascript ref:
import { isValidHexKey } from '../constants.js'
import { formatErr } from '../error-prefixes.js'
import { validatePositiveInt } from './api-validation.js'

export const DEVICE_NAME_MAX_LENGTH = 80
export const MAX_DEVICE_LIST_ENTRIES = 128
const DEVICE_PERSIST_FAILED_MESSAGE = 'failed to persist device allowlist; check storage permissions and disk space'
const DEVICE_PAIRING_MANAGEMENT_ROUTES = Object.freeze({
  'POST /api/manage/devices': Object.freeze({ kind: 'device-management' }),
  'POST /api/manage/pairing': Object.freeze({ kind: 'pairing-management' })
})

export function resolveDevicePairingManagementRoute (method, path) {
  const route = DEVICE_PAIRING_MANAGEMENT_ROUTES[`${method} ${path}`]
  if (!route) return null
  return { ...route }
}

function errorPayload (message) {
  return { error: message }
}

function errorMessage (err) {
  return err && err.message ? err.message : String(err || 'unknown error')
}

export function devicePersistFailureResult ({
  error,
  emit = null
}) {
  if (typeof emit === 'function') {
    emit('device-persist-error', {
      message: errorMessage(error),
      error
    })
  }

  return {
    ok: false,
    kind: 'device-persist',
    status: 500,
    payload: {
      error: formatErr('PERSIST_FAILED', DEVICE_PERSIST_FAILED_MESSAGE),
      errorCode: 'persist-failed'
    }
  }
}

function isKnownDeviceOperatorError (message) {
  return message === 'Device not in allowlist' || message.startsWith('Maximum devices reached')
}

export function normalizeDeviceName (name) {
  if (name === undefined || name === null || name === '') return { ok: true, value: 'manual' }
  if (typeof name !== 'string') return { ok: false, error: 'name must be a string' }
  const value = name.trim() || 'manual'
  if (value.length > DEVICE_NAME_MAX_LENGTH) {
    return { ok: false, error: `name exceeds max length (${DEVICE_NAME_MAX_LENGTH})` }
  }
  if (hasControlChar(value)) {
    return { ok: false, error: 'name must not contain control characters' }
  }
  return { ok: true, value }
}

export function sanitizeDeviceList (devices) {
  const source = Array.isArray(devices) ? devices : []
  const out = []
  for (const entry of source) {
    if (out.length >= MAX_DEVICE_LIST_ENTRIES) break
    const clean = sanitizeDeviceEntry(entry)
    if (clean) out.push(clean)
  }
  return out
}

export function sanitizeDeviceEntry (entry) {
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
  if (!entry.pubkey || !isValidHexKey(entry.pubkey, 64)) return null
  const name = normalizeDeviceName(entry.name)
  return {
    pubkey: String(entry.pubkey).toLowerCase(),
    name: name.ok ? name.value : 'manual',
    pairedAt: safeTimestamp(entry.pairedAt),
    lastSeen: safeTimestamp(entry.lastSeen)
  }
}

function safeTimestamp (value) {
  return Number.isSafeInteger(value) && value >= 0 ? value : null
}

function hasControlChar (value) {
  for (let i = 0; i < value.length; i++) {
    const code = value.charCodeAt(i)
    if (code <= 0x1f || code === 0x7f) return true
  }
  return false
}

export async function runDeviceManagementAction ({
  body = {},
  node,
  emit = null
}) {
  body = body || {}

  if (!node.accessControl) {
    return {
      ok: false,
      kind: 'bad-request',
      status: 400,
      payload: {
        error: 'Access control is not active in current mode',
        mode: node.mode
      }
    }
  }

  const action = body.action || 'list'
  if (action === 'list') {
    const source = node.listDevices()
    const devices = sanitizeDeviceList(source)
    return {
      ok: true,
      payload: {
        ok: true,
        count: devices.length,
        total: Array.isArray(source) ? source.length : devices.length,
        truncated: Array.isArray(source) && source.length > devices.length,
        devices
      }
    }
  }

  if (action === 'add') {
    if (!body.pubkey || !isValidHexKey(body.pubkey, 64)) {
      return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload('pubkey must be 64 hex characters') }
    }
    const name = normalizeDeviceName(body.name)
    if (!name.ok) return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload(name.error) }

    const pubkey = body.pubkey.toLowerCase()
    try {
      await node.addDevice(pubkey, name.value)
    } catch (err) {
      const message = errorMessage(err)
      if (isKnownDeviceOperatorError(message)) {
        return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload(message) }
      }
      return devicePersistFailureResult({ error: err, emit })
    }

    return { ok: true, payload: { ok: true, action: 'added', pubkey, name: name.value } }
  }

  if (action === 'remove') {
    if (!body.pubkey || !isValidHexKey(body.pubkey, 64)) {
      return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload('pubkey must be 64 hex characters') }
    }

    const pubkey = body.pubkey.toLowerCase()
    try {
      await node.removeDevice(pubkey)
    } catch (err) {
      const message = errorMessage(err)
      if (isKnownDeviceOperatorError(message)) {
        return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload(message) }
      }
      return devicePersistFailureResult({ error: err, emit })
    }

    return { ok: true, payload: { ok: true, action: 'removed', pubkey } }
  }

  return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload('Unknown action (use list, add, remove)') }
}

export function runPairingManagementAction ({
  body = {},
  node
}) {
  body = body || {}

  if (!node.accessControl) {
    return {
      ok: false,
      kind: 'bad-request',
      status: 400,
      payload: {
        error: 'Pairing is not available in current mode',
        mode: node.mode
      }
    }
  }

  const action = body.action || 'status'
  if (action === 'status') {
    const state = node.accessControl._pairingState
    return {
      ok: true,
      payload: {
        ok: true,
        active: node.accessControl.isPairing,
        expiresAt: state ? state.expiresAt : null
      }
    }
  }

  if (action === 'start') {
    let timeoutMs
    if (body.timeoutMs !== undefined) {
      const result = validatePositiveInt(body.timeoutMs, 10_000, 30 * 60 * 1000, 'timeoutMs')
      if (!result.ok) return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload(result.error) }
      timeoutMs = result.value
    }
    const pairing = node.enablePairing({ timeoutMs })
    return {
      ok: true,
      payload: {
        ok: true,
        active: true,
        ...pairing
      }
    }
  }

  if (action === 'stop') {
    node.accessControl.disablePairing()
    return { ok: true, payload: { ok: true, active: false } }
  }

  return { ok: false, kind: 'bad-request', status: 400, payload: errorPayload('Unknown action (use status, start, stop)') }
}

export async function runDevicePairingManagementRouteAction ({
  route,
  body = {},
  node,
  emit = null
} = {}) {
  if (!route) {
    return { ok: false, kind: 'not-found', status: 404, payload: errorPayload('unknown device/pairing management route') }
  }

  if (route.kind === 'device-management') {
    return runDeviceManagementAction({ body, node, emit })
  }

  if (route.kind === 'pairing-management') {
    return runPairingManagementAction({ body, node })
  }

  return { ok: false, kind: 'not-found', status: 404, payload: errorPayload('unknown device/pairing management route') }
}