OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / shard-store / shard-pin.js

299 lines · javascript ref:
/**
 * Shard pins — the authorization + retention record for a stored shard.
 *
 * A shard is retained while it has >=1 live (unexpired) pin. A pin is signed by
 * its `pinner` and authorized by one of:
 *   - custody: the pinned hash is bound to a shareIndex assigned to this relay
 *     in a valid signed custody-intent's shareManifest.
 *   - payment: the pinner is within a paid/quota budget (relay-supplied check).
 *   - token: an operator-issued bearer (relay-supplied check).
 *
 * The pin registry persists pins and re-verifies every pin signature on load,
 * dropping unverifiable rows — the persisted file is not a trust root (same
 * posture as the outboxlog #146 fix).
 */
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { normalizeShardAddress, shardError } from './shard-engine.js'

export const SHARD_PIN_DOMAIN = 'hiverelay.shard-pin.v1'
export const SHARD_PIN_REASONS = Object.freeze(['custody', 'payment', 'token'])

const HEX64 = /^[0-9a-f]{64}$/
const HEX_SIG = /^[0-9a-f]{128}$/

// Deterministic serialization of the pin body (sig excluded), sorted keys.
function stable (value) {
  if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'
  if (value && typeof value === 'object') {
    return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stable(value[k])).join(',') + '}'
  }
  return JSON.stringify(value === undefined ? null : value)
}

function pinBody (pin) {
  return {
    reason: pin.reason,
    hash: pin.hash,
    pinner: pin.pinner,
    custodyIntentId: pin.custodyIntentId || null,
    shareIndex: Number.isInteger(pin.shareIndex) ? pin.shareIndex : null,
    retainUntil: pin.retainUntil,
    nonce: pin.nonce
  }
}

export function shardPinSignable (pin) {
  return b4a.from(SHARD_PIN_DOMAIN + '\0' + stable(pinBody(pin)), 'utf8')
}

/** pinRef = a stable id for a pin (hash of its signed body) — the unpin key. */
export function shardPinRef (pin) {
  const out = b4a.alloc(32)
  sodium.crypto_generichash(out, shardPinSignable(pin))
  return b4a.toString(out, 'hex')
}

export function signShardPin (pin, keyPair) {
  const pinner = pin.pinner || b4a.toString(keyPair.publicKey, 'hex')
  const body = { ...pin, pinner }
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(sig, shardPinSignable(body), keyPair.secretKey)
  return { ...body, sig: b4a.toString(sig, 'hex') }
}

export function verifyShardPin (pin) {
  if (!pin || typeof pin !== 'object') return false
  if (!SHARD_PIN_REASONS.includes(pin.reason)) return false
  if (!HEX64.test(String(pin.hash || '').toLowerCase())) return false
  if (!HEX64.test(String(pin.pinner || '').toLowerCase())) return false
  if (typeof pin.sig !== 'string' || !HEX_SIG.test(pin.sig)) return false
  if (!Number.isFinite(pin.retainUntil)) return false
  if (pin.reason === 'custody' && (!pin.custodyIntentId || !Number.isInteger(pin.shareIndex))) return false
  try {
    return sodium.crypto_sign_verify_detached(
      b4a.from(pin.sig, 'hex'),
      shardPinSignable(pin),
      b4a.from(pin.pinner, 'hex')
    )
  } catch {
    return false
  }
}

/**
 * Authorize a PUT. Returns the accepted (normalized) pin or throws.
 * @param resolveCustodyAssignment (custodyIntentId, relayPubkey) =>
 *        { shareIndex, shard } | null  — from a VERIFIED custody-intent.
 * @param checkPaymentQuota (pinner, byteLength) => boolean
 * @param checkToken (pin) => boolean
 */
export async function authorizeShardPin (pin, {
  hash,
  byteLength,
  relayPubkey,
  allowedReasons,
  resolveCustodyAssignment,
  checkPaymentQuota,
  checkToken
} = {}) {
  if (!verifyShardPin(pin)) throw shardError('BAD_SIGNATURE', 'pin signature invalid')
  if (normalizeShardAddress(pin.hash) !== hash) throw shardError('UNAUTHORIZED_PIN', 'pin does not name this shard')
  if (Array.isArray(allowedReasons) && !allowedReasons.includes(pin.reason)) {
    throw shardError('UNAUTHORIZED_PIN', 'pin reason not permitted by operator config')
  }
  if (pin.reason === 'custody') {
    if (typeof resolveCustodyAssignment !== 'function') throw shardError('UNAUTHORIZED_PIN', 'custody binding unavailable')
    const assign = await resolveCustodyAssignment(pin.custodyIntentId, relayPubkey)
    if (!assign) throw shardError('UNAUTHORIZED_PIN', 'no custody assignment for this relay')
    if (assign.shareIndex !== pin.shareIndex) throw shardError('UNAUTHORIZED_PIN', 'shareIndex does not match assignment')
    if (normalizeShardAddress(assign.shard) !== hash) throw shardError('UNAUTHORIZED_PIN', 'assigned share hash does not match')
    return pin
  }
  if (pin.reason === 'payment') {
    if (typeof checkPaymentQuota !== 'function' || !(await checkPaymentQuota(pin.pinner, byteLength))) {
      throw shardError('QUOTA_EXHAUSTED', 'pinner over quota / payment budget')
    }
    return pin
  }
  if (pin.reason === 'token') {
    if (typeof checkToken !== 'function' || !(await checkToken(pin))) {
      throw shardError('UNAUTHORIZED_PIN', 'invalid capability token')
    }
    return pin
  }
  throw shardError('UNAUTHORIZED_PIN', 'unsupported pin reason')
}

export class ShardPinRegistry {
  constructor ({ persistence = null, clock = () => Date.now(), persistFlushMs = 250 } = {}) {
    this.persistence = persistence
    this.clock = clock
    this.persistFlushMs = persistFlushMs
    this.pins = new Map() // hash -> Map(pinRef -> pin)
    this._persistTimer = null
    this._persistChain = Promise.resolve()
  }

  async load () {
    if (!this.persistence) return
    const snapshot = await this.persistence.load()
    if (!snapshot || !Array.isArray(snapshot.pins)) return
    for (const pin of snapshot.pins) {
      // The persisted file is not a trust root: re-verify every pin, drop bad.
      if (!verifyShardPin(pin)) continue
      this._apply(pin)
    }
  }

  _apply (pin) {
    const ref = shardPinRef(pin)
    let set = this.pins.get(pin.hash)
    if (!set) { set = new Map(); this.pins.set(pin.hash, set) }
    const collapsed = set.has(ref) // an identical signed pin (same pinner+nonce+hash) already exists
    set.set(ref, pin)
    return { ref, collapsed }
  }

  /**
   * Add a pre-verified/authorized pin. Returns its pinRef.
   *
   * A pinRef is the hash of the pin's signed body, so a byte-identical signed
   * pin (same pinner + nonce + hash — e.g. an idempotent network retry of the
   * same signed PUT) collapses onto the SAME pinRef and does NOT grow the pin
   * set. `addPin()` exposes that collapse so the engine can keep its dedup
   * ref-count 1:1 with DISTINCT pins (see index.js put()); otherwise a
   * collapsed duplicate would leave an engine ref no pin can ever release
   * (permanent byte orphan on expiry+sweep).
   */
  add (pin) {
    return this.addPin(pin).ref
  }

  /** Like add(), but returns { ref, collapsed } so callers can detect a
   *  duplicate signed pin that collapsed onto an existing pinRef. */
  addPin (pin) {
    const res = this._apply(pin)
    this._schedulePersist()
    return res
  }

  /** Remove a pin; the remover must prove pinner control (a signed removal). */
  remove (hash, pinRef, removal) {
    const set = this.pins.get(hash)
    if (!set || !set.has(pinRef)) return { removed: false, refs: this.refs(hash) }
    const pin = set.get(pinRef)
    // removal is a fresh pin-shaped object (same pinner) proving control.
    if (!removal || removal.pinner !== pin.pinner || !verifyShardPin(removal)) {
      throw shardError('BAD_SIGNATURE', 'unpin must be signed by the pinner')
    }
    set.delete(pinRef)
    if (set.size === 0) this.pins.delete(hash)
    this._schedulePersist()
    return { removed: true, refs: this.refs(hash) }
  }

  livePins (hash, now = this.clock()) {
    const set = this.pins.get(hash)
    if (!set) return []
    return [...set.values()].filter(p => p.retainUntil > now)
  }

  refs (hash, now = this.clock()) {
    return this.livePins(hash, now).length
  }

  retainUntil (hash, now = this.clock()) {
    return this.livePins(hash, now).reduce((max, p) => Math.max(max, p.retainUntil), 0)
  }

  /** All hashes with at least one live pin. */
  liveHashes (now = this.clock()) {
    const out = []
    for (const [hash] of this.pins) if (this.refs(hash, now) > 0) out.push(hash)
    return out
  }

  /** Forcibly drop every pin for a hash (operator/eviction purge). */
  purgeHash (hash) {
    if (this.pins.delete(hash)) this._schedulePersist()
  }

  /** hashes whose every pin has expired (candidates for GC). */
  expiredHashes (now = this.clock()) {
    const out = []
    for (const [hash] of this.pins) if (this.refs(hash, now) === 0) out.push(hash)
    return out
  }

  /**
   * hashes with AT LEAST ONE expired pin (whether or not a live pin remains).
   * The sweep visits these so an expired pin on a still-live hash is purged and
   * its engine dedup ref reconciled away — without ever deleting the bytes a
   * remaining live pin still references (STO-002).
   */
  hashesWithExpiredPins (now = this.clock()) {
    const out = []
    for (const [hash, set] of this.pins) {
      for (const pin of set.values()) {
        if (!(pin.retainUntil > now)) { out.push(hash); break }
      }
    }
    return out
  }

  /**
   * Drop only the EXPIRED pins for one hash and return how many were removed.
   * Live (still-valid retainUntil) pins are left intact — critical for the
   * sweep-vs-PUT race: a fresh pin added concurrently for an expiring hash
   * survives, so its shard is never wiped (STO-002). If the hash has no pins
   * left afterwards the map row is dropped.
   */
  purgeExpiredPins (hash, now = this.clock()) {
    const set = this.pins.get(hash)
    if (!set) return 0
    let removed = 0
    for (const [ref, pin] of set) {
      if (!(pin.retainUntil > now)) { set.delete(ref); removed++ }
    }
    if (set.size === 0) this.pins.delete(hash)
    if (removed) this._schedulePersist()
    return removed
  }

  /** Drop all fully-expired hashes from the registry. Returns the purged list. */
  purgeExpired (now = this.clock()) {
    const purged = this.expiredHashes(now)
    for (const hash of purged) this.pins.delete(hash)
    if (purged.length) this._schedulePersist()
    return purged
  }

  snapshot () {
    const pins = []
    for (const set of this.pins.values()) for (const pin of set.values()) pins.push(pin)
    return { version: 1, pins }
  }

  _schedulePersist () {
    if (!this.persistence || this._persistTimer) return
    this._persistTimer = setTimeout(() => {
      this._persistTimer = null
      this.flush().catch(() => {})
    }, this.persistFlushMs)
    if (this._persistTimer.unref) this._persistTimer.unref()
  }

  async flush () {
    if (!this.persistence) return
    if (this._persistTimer) { clearTimeout(this._persistTimer); this._persistTimer = null }
    const snap = this.snapshot()
    this._persistChain = this._persistChain.catch(() => {}).then(() => this.persistence.save(snap))
    await this._persistChain
  }

  async close () {
    await this.flush()
  }
}