// 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
}