OverviewFilesCommitsIssuesDocsReleasesPeers

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

199 lines · javascript ref:
import { join } from 'node:path'
import { ServiceProvider } from 'p2p-hiverelay/core/services/provider.js'
import {
  createJsonFileOutboxPersistence,
  createOutboxLog
} from '../outboxlog/index.js'
import {
  WITNESSLOG_NAMESPACE,
  assertWitnessRecord,
  normalizeWitnessOutboxRecord,
  redactWitnessRecord,
  stripOutboxFields,
  targetMatchesWitnessRecord,
  verifyWitnessOutboxRecord,
  witnessRecordMarker
} from './record.js'

export class WitnessLogApp extends ServiceProvider {
  constructor (opts = {}) {
    super()
    this.engine = opts.engine || createOutboxLog({
      namespace: WITNESSLOG_NAMESPACE,
      namespaces: {
        [WITNESSLOG_NAMESPACE]: {
          blind: false,
          caps: opts.caps || {
            maxOutboxes: opts.maxOutboxes || 50000,
            maxEntriesPerOutbox: opts.maxEntriesPerOutbox || 10000,
            maxValueBytes: opts.maxValueBytes || 4096
          }
        }
      },
      verifyAppend: (input) => verifyWitnessOutboxRecord(input, this.verifyOpts),
      persistence: opts.persistence === undefined ? null : opts.persistence,
      log: opts.log
    })
    this.sync = this.engine.sync
    this.verifyOpts = opts.verify || {}
    this.persistencePath = typeof opts.persistencePath === 'string' ? opts.persistencePath : null
    this.storagePath = typeof opts.storagePath === 'string' ? opts.storagePath : null
    this.persistenceDisabled = opts.persistence === false
    this.node = null
  }

  manifest () {
    return {
      name: 'witnesslog',
      version: '0.1.0',
      description: 'Signed third-party availability observations for HiveRelay targets',
      capabilities: ['witnesslog.append', 'witnesslog.range', 'witnesslog.events']
    }
  }

  async start (context = {}) {
    this.node = context.node || null
    const config = context && context.config && typeof context.config === 'object' ? context.config : {}
    const witnesslog = config.witnesslog && typeof config.witnesslog === 'object' ? config.witnesslog : {}
    if (!this.persistenceDisabled && this.engine.configurePersistence) {
      const storagePath = this.storagePath ||
        (typeof witnesslog.storagePath === 'string' ? witnesslog.storagePath : null) ||
        (typeof config.storage === 'string' ? config.storage : null)
      const persistencePath = this.persistencePath ||
        (typeof witnesslog.persistencePath === 'string' ? witnesslog.persistencePath : null) ||
        (storagePath ? join(storagePath, 'witnesslog-state.json') : null)
      if (persistencePath) {
        this.engine.configurePersistence({
          persistence: createJsonFileOutboxPersistence(persistencePath)
        })
      }
    }
  }

  async stop () {
    if (this.engine.flush) await this.engine.flush()
    this.node = null
  }

  append (record) {
    const signed = assertWitnessRecord(record, this.verifyOpts)
    const data = normalizeWitnessOutboxRecord(signed)
    const appId = signed.observer.publicKey
    return {
      ...this.sync.append(appId, { type: 'availability', data }),
      id: signed.id
    }
  }

  list (opts = {}) {
    const limit = clampLimit(opts.limit)
    const since = normalizeSince(opts.since)
    const target = opts.target || opts.key || opts.keyHash || null
    const rows = []
    const snapshot = this.engine.snapshot()
    for (const group of snapshot.groups || []) {
      const groupState = Array.isArray(group) ? group[1] : null
      if (!groupState || !Array.isArray(groupState.rows)) continue
      for (const row of groupState.rows) {
        if (!Array.isArray(row) || row.length !== 2) continue
        const value = row[1]
        if (!value || value._ns !== WITNESSLOG_NAMESPACE) continue
        let record
        try {
          record = stripOutboxFields(value)
          assertWitnessRecord(record, this.verifyOpts)
        } catch {
          continue
        }
        if (since && Date.parse(record.observedAt) <= since) continue
        if (!targetMatchesWitnessRecord(record, target)) continue
        rows.push(record)
      }
    }
    rows.sort(compareWitnessRecords)
    const page = rows.slice(0, limit)
    return {
      records: opts.redact === false ? page : page.map(redactWitnessRecord),
      count: page.length,
      total: rows.length,
      nextSince: page.length ? page[page.length - 1].observedAt : (opts.since || null),
      hasMore: rows.length > page.length
    }
  }

  markers (opts = {}) {
    const page = this.list({ ...opts, redact: false })
    return {
      markers: page.records.map(witnessRecordMarker),
      count: page.count,
      total: page.total,
      nextSince: page.nextSince,
      hasMore: page.hasMore
    }
  }

  subscribe (opts, fn) {
    if (typeof opts === 'function') {
      fn = opts
      opts = {}
    }
    if (typeof fn !== 'function') throw new Error('WitnessLog.subscribe: not a function')
    const target = opts && (opts.target || opts.key || opts.keyHash)
    return this.engine.subscribe('*', { replay: false }, event => {
      if (!event || !event.value || event.value._ns !== WITNESSLOG_NAMESPACE) return
      let record
      try {
        record = stripOutboxFields(event.value)
        assertWitnessRecord(record, this.verifyOpts)
      } catch {
        return
      }
      if (!targetMatchesWitnessRecord(record, target)) return
      fn({
        ...witnessRecordMarker(record),
        seq: event.seq,
        topic: 'witness/' + (record.target.keyHash || record.target.key || 'unknown')
      })
    })
  }
}

export {
  WITNESSLOG_DEFAULT_MAX_AGE_MS,
  WITNESSLOG_DEFAULT_MAX_CLOCK_SKEW_MS,
  WITNESSLOG_DEFAULT_MAX_TTL_MS,
  WITNESSLOG_NAMESPACE,
  WITNESSLOG_RECORD_TYPE_AVAILABILITY,
  WITNESSLOG_RECORD_VERSION,
  WITNESSLOG_SIGNATURE_DOMAIN,
  assertWitnessRecord,
  canonicalWitnessRecord,
  createWitnessRecord,
  normalizeWitnessOutboxRecord,
  normalizeWitnessRecord,
  redactWitnessRecord,
  signWitnessRecord,
  stripOutboxFields,
  targetMatchesWitnessRecord,
  verifyWitnessOutboxRecord,
  verifyWitnessRecord,
  witnessRecordBytes,
  witnessRecordMarker
} from './record.js'

function compareWitnessRecords (a, b) {
  return Date.parse(a.observedAt) - Date.parse(b.observedAt) || a.id.localeCompare(b.id)
}

function clampLimit (value) {
  const number = Number(value)
  if (!Number.isSafeInteger(number) || number < 1) return 100
  return Math.min(number, 1000)
}

function normalizeSince (value) {
  if (value == null || value === '') return null
  const ms = Date.parse(value)
  return Number.isFinite(ms) ? ms : null
}