OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / config.js

127 lines · javascript ref:
// config.js — env-driven, same shape as the rest of the fleet (env > file > default).
//
// Deliberately small. M1 fronts ONE actor with the key held by the bridge — that is a documented
// deviation from the target architecture (DESIGN.md §11 M1), taken so that a failed test has one
// cause instead of two. Multi-actor and the virtual-ap-device RPC seam land in M2.

import fs from 'bare-fs'
import process from 'bare-process'

const DEFAULTS = {
  host: 'p2p.7rnx.net', // the PUBLIC hostname; must match what the proxy serves and what WebFinger claims
  username: 'p2p',
  displayName: null,
  summary: '',
  alsoKnownAs: [],
  did: null, // the atproto DID this actor projects; enables the keyless record read
  // ⛔ The favela peer's socket. When set, records come from the peer's MARKDOWN and this actor
  // needs no DID at all. Mutually exclusive with `did` — see index.js.
  favelaSocket: null,
  // ⚠ What kind of room this identity's own feed is. 'public' is an ASSUMPTION the reach lattice
  // cannot infer, and if it is wrong it is wrong toward a leak.
  roomType: 'public',
  pdsUrl: 'https://pds.7rnx.net', // where blob bytes are fetched FROM (the published URL stays ours)
  backfill: 0, // ONE-SHOT: deliver the N most recent existing posts on first prime (see publisher.js) // cross-network identity links, e.g. the at:// DID this actor is also known as
  port: 3400,
  bind: '127.0.0.1', // behind the 7rnx proxy over the VPN — never bind public directly
  privateKeyPath: null, // PEM on disk; minted once with openssl (see README)
  publicKeyPath: null,
  dataDir: null, // follower state etc.
  maxSkewSeconds: 300,
  // ⛔ HOW LONG A PEER MAY BE AWAY, not how much history exists. The bridge pushes inbound to the peer
  // that proved it holds the name, so the PEER is the durable copy and this is a buffer for peers that
  // are offline. It was hardcoded at 500 in inbound-store.js.
  //
  // ⚠ AND FOR THE FEDIVERSE IT IS A REAL LOSS BOUNDARY, unlike atproto. atproto inbound can be rebuilt
  // from Constellation, a backlink INDEX that answers "what links to this subject?" for any point in
  // the past. ActivityPub is push-only delivery into an inbox and no such index exists — an evicted
  // Like can be recovered only by asking the original object's own collection, which Mastodon serves
  // partially or not at all. So evicting here can destroy the only copy, and the cap should be
  // generous rather than tight.
  inboundCap: 500,
  // The admin UI. ABSENT means it is not served at all — an operator surface a bridge starts offering
  // because nobody said not to is the same mistake as registration defaulting to open.
  adminPort: null,
  // ⛔ ITS OWN BIND, DEFAULTING TO LOOPBACK, AND NEVER config.bind. This deployment sets AP_BIND to
  // 0.0.0.0 for the PUBLIC listener; inheriting that would publish the operator surface the moment
  // somebody made the fediverse one reachable. Widening this is a separate, deliberate act — and it is
  // only safe because the surface is authenticated: an ANProto key proves itself, the first claim needs
  // the bootstrap token from the service log, and everything else needs an invite.
  adminBind: '127.0.0.1'
}

function expand (p) {
  if (typeof p !== 'string') return p
  return p.replace(/^~/, process.env.HOME || '').replace(/\$\{HOME\}/g, process.env.HOME || '')
}

export function loadConfig (env = process.env) {
  const file = env.AP_CONFIG ? JSON.parse(fs.readFileSync(expand(env.AP_CONFIG), 'utf8')) : {}

  const cfg = {
    ...DEFAULTS,
    ...file,
    ...pick(env, {
      host: 'AP_HOST',
      username: 'AP_USERNAME',
      displayName: 'AP_DISPLAY_NAME',
      summary: 'AP_SUMMARY',
      did: 'AP_DID',
      pdsUrl: 'AP_PDS_URL',
      privateKeyPath: 'AP_PRIVATE_KEY',
      publicKeyPath: 'AP_PUBLIC_KEY',
      dataDir: 'AP_DATA_DIR',
      bind: 'AP_BIND',
      favelaSocket: 'AP_FAVELA_SOCKET',
      roomType: 'AP_ROOM_TYPE',
      // ⛔ 'open' or 'invite'. ABSENT means registration is off entirely and /ap/register 404s —
      // a bridge must not start handing out names on the operator's domain because nobody said not to.
      registration: 'AP_REGISTRATION',
      adminBind: 'AP_ADMIN_BIND'
    }),
    ...pickInt(env, {
      port: 'AP_PORT',
      maxSkewSeconds: 'AP_MAX_SKEW',
      backfill: 'AP_BACKFILL',
      inboundCap: 'AP_INBOUND_CAP',
      adminPort: 'AP_ADMIN_PORT'
    })
  }

  // Comma-separated in env; an array in a config file.
  if (env.AP_ALSO_KNOWN_AS) {
    cfg.alsoKnownAs = env.AP_ALSO_KNOWN_AS.split(',').map((s) => s.trim()).filter(Boolean)
  }

  for (const k of ['privateKeyPath', 'publicKeyPath', 'dataDir']) cfg[k] = expand(cfg[k])
  return cfg
}

function pick (env, map) {
  const out = {}
  for (const [key, name] of Object.entries(map)) if (env[name] !== undefined) out[key] = env[name]
  return out
}

function pickInt (env, map) {
  const out = {}
  for (const [key, name] of Object.entries(map)) {
    if (env[name] !== undefined) {
      const n = parseInt(env[name], 10)
      if (Number.isFinite(n)) out[key] = n
    }
  }
  return out
}

// Fail at boot with a useful message rather than at first request with a stack trace.
export function assertUsable (cfg) {
  const missing = []
  if (!cfg.host) missing.push('AP_HOST')
  if (!cfg.username) missing.push('AP_USERNAME')
  if (!cfg.privateKeyPath) missing.push('AP_PRIVATE_KEY')
  if (!cfg.publicKeyPath) missing.push('AP_PUBLIC_KEY')
  if (missing.length) throw new Error(`ap-bridge: missing required config: ${missing.join(', ')}`)
  return cfg
}