OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / services / index-sidecar / lib / schemas.js

137 lines · javascript ref:
// The four index schemas (Design B §3.2). These are plain JSON Schema
// (draft-07) registered into the schema-sheets room via addNewSchema, and
// also used here to validate mapper output before a row is written.
//
// Trust model: the room is an INDEX, not an authority. relay-directory rows
// carry capabilitySig so clients re-verify via verifyCapabilityDoc without
// trusting the room writer; manifest rows carry keet attestations; anchored
// claims are re-checked against the relay's anchor-proof route. See README.
import Ajv from 'ajv'
import addFormats from 'ajv-formats'

export const SCHEMA_NAMES = ['pin-registry', 'relay-directory', 'app-manifest', 'verification']

// NOTE: no `$id` on any schema — schema-sheets compiles each row's schema in
// its own Ajv keyed by $id, so a duplicate $id across row applies throws
// "schema with id already exists". Schemas are identified by the name passed
// to addNewSchema, not by $id.
export const PIN_REGISTRY_SCHEMA = {
  type: 'object',
  properties: {
    appKey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
    type: { type: 'string' },
    name: { type: 'string' },
    version: { type: 'string' },
    sizeBytes: { type: 'number', minimum: 0 },
    anchored: { type: 'boolean' },
    anchoredLength: { type: 'number', minimum: 0 },
    // NB: pending/rejected are reserved for the Phase-2 operator-authed feed
    // (pending-seeds.json); the v1 catalogue projector only emits
    // accepted/anchored/unseeded. See mappers.pinRegistryRow + INDEX-LAYER.md.
    seedState: { type: 'string', enum: ['pending', 'accepted', 'anchored', 'unseeded', 'rejected'] },
    relayPubkey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
    verified: { type: 'boolean' }
  },
  required: ['appKey'],
  // Closed: the row schema is the last structural guarantee of what becomes
  // public. Every field the mapper emits is enumerated above.
  additionalProperties: false
}

export const RELAY_DIRECTORY_SCHEMA = {
  type: 'object',
  properties: {
    pubkey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
    gatewayUrl: { type: 'string' },
    software: { type: 'string' },
    version: { type: ['string', 'null'] },
    region: { type: ['string', 'null'] },
    features: { type: 'array', items: { type: 'string' } },
    limitation: { type: 'object' },
    health: {
      type: 'object',
      properties: {
        anchoredCount: { type: 'number' },
        totalCount: { type: 'number' },
        anchorRatio: { type: 'number' },
        lastSeen: { type: 'number' }
      }
    },
    capacity: { type: 'object' },
    reputation: { type: 'object' },
    // doc.signature copied verbatim (convenience); re-verification uses `doc`.
    capabilitySig: { type: ['object', 'null'] },
    // The full signed capability doc, verbatim — clients run
    // verifyCapabilityDoc(doc) on this (it has the original field set +
    // `signature`), then trust the derived view above.
    doc: { type: ['object', 'null'] }
  },
  required: ['pubkey', 'gatewayUrl'],
  additionalProperties: false
}

export const APP_MANIFEST_SCHEMA = {
  type: 'object',
  properties: {
    appId: { type: 'string' },
    name: { type: 'string' },
    driveKey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
    link: { type: 'string' },
    type: { type: 'string', enum: ['standalone', 'hypersite'] },
    catalogType: { type: ['string', 'null'] },
    manifestHash: { type: 'string' },
    author: { type: ['string', 'null'] },
    version: { type: ['string', 'null'] },
    icon: { type: ['string', 'null'] },
    categories: { type: 'array', items: { type: 'string' } },
    publisherPubkey: { type: ['string', 'null'] },
    publishedAt: { type: ['number', 'null'] },
    sizeBytes: { type: ['number', 'null'] }
  },
  required: ['appId', 'name'],
  // installable (driveKey) and/or launchable (link) — at least one.
  anyOf: [
    { required: ['driveKey'] },
    { required: ['link'] }
  ],
  // Closed: every field the mapper emits is enumerated above (last structural
  // guarantee of what becomes public).
  additionalProperties: false
}

export const VERIFICATION_SCHEMA = {
  type: 'object',
  properties: {
    subjectAppKey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
    verifierPubkey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
    verdict: { type: 'string', enum: ['agree', 'diverge', 'anchored', 'unanchored'] },
    method: { type: 'string', enum: ['capability-divergence', 'anchor-proof', 'catalog-divergence'] },
    anchorProofSig: { type: ['string', 'null'] },
    attestedAt: { type: ['number', 'null'] }
  },
  required: ['subjectAppKey', 'verifierPubkey', 'verdict'],
  additionalProperties: false
}

export const SCHEMAS = {
  'pin-registry': PIN_REGISTRY_SCHEMA,
  'relay-directory': RELAY_DIRECTORY_SCHEMA,
  'app-manifest': APP_MANIFEST_SCHEMA,
  verification: VERIFICATION_SCHEMA
}

// One Ajv instance compiling all four validators. Used to reject malformed
// rows before they hit the (append-only, write-amplifying) room.
const ajv = new Ajv({ allErrors: true, strict: false })
addFormats(ajv)
const validators = Object.fromEntries(
  Object.entries(SCHEMAS).map(([name, schema]) => [name, ajv.compile(schema)])
)

export function validateRow (schemaName, row) {
  const validate = validators[schemaName]
  if (!validate) return { valid: false, errors: ['unknown schema: ' + schemaName] }
  const valid = validate(row)
  return { valid, errors: valid ? [] : (validate.errors || []).map(e => `${e.instancePath || '/'} ${e.message}`) }
}