// registrations.js — who may have a name on this bridge.
//
// ⛔ A BRIDGE IS AN OWNED RESOURCE, NOT A COMMONS. That is the whole reason this differs from
// favela's NameRegistry, where `opAuthorised` binds release to the claim's own publisher — so a
// squatted name is permanent, which hyper-social #59 §3 found and filed as pear-git #43. A bridge
// runs on somebody's domain: the operator owns the namespace under it, and gets the arbiter the
// commons deliberately cannot have. First come first served; the holder OR the operator may release.
//
// ⛔ AND A RELEASE BURNS THE IDENTITY, not just the string. AP object ids are `/ap/users/<name>/…`.
// If alice registers, federates fifty posts, releases, and bob then claims `alice`, bob inherits her
// actor URI and every object id — and every remote server that cached her now follows him. That is
// impersonation by name reuse. The `generation` counter is what stops it: it increments on every
// re-claim, ids derive from it, and a reused name therefore cannot collide with its predecessor's
// audience. The tombstone is kept for the same reason a record's deletion is kept — absence would
// make "never registered" and "registered then released" identical, and the second is the one a
// federated peer may still hold references into.
//
// Policy (hyper-social #65 §8, preserved rather than relaxed):
//
// open request + chain proof → registered
// invite bridge issues invite; invite + proof → registered
//
// §8's rule is that admission needs CONSENT and CONTROL, and neither substitutes for the other:
// "an invite without control lets an invited user claim someone else's identity; control without an
// invite lets anyone use your bridge." Open registration does not drop a half — it replaces the
// PER-USER invite with a STANDING POLICY consent. Control is proved identically either way.
//
// ⚠ This module holds the REGISTRY. It does not verify the chain proof: that is
// `favela-identity`'s `readAttested`, which is pure and has no filesystem, network or clock, and it
// belongs at the route where the challenge lives. Here a `root` is required and its SHAPE checked;
// proving the caller controls it is the caller's job and must happen before claim() is reached.
import fs from 'bare-fs'
import crypto from 'bare-crypto'
import b4a from 'b4a'
import { dirname } from 'bare-path'
const HEX64 = /^[0-9a-f]{64}$/i
// A username becomes a path segment in a public URI, so it is deliberately narrower than a favela
// handle: lowercase, no dots (they read as domains), no separators to escape.
const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
const badName = (s) => typeof s !== 'string' || !NAME.test(s)
class BaseRegistrations {
constructor ({ policy = 'invite' } = {}) {
// ⛔ Defaults to CLOSED. A bridge that opens itself because nobody said otherwise is a bridge
// whose operator did not choose to be open.
this.policy = policy === 'open' ? 'open' : 'invite'
}
async get (username) {
if (badName(username)) return null
return (await this._read()).names[username] || null
}
/** The tombstone for a released name, or null. Kept — see the header. */
async released (username) {
if (badName(username)) return null
return (await this._read()).burned[username] || null
}
async list () { return Object.values((await this._read()).names) }
/**
* Every name that has been RELEASED, with its tombstone.
*
* ⛔ Operators need to see these, because a burned name is not a free name: the generation counter
* makes a reused name a DIFFERENT actor so it cannot inherit the previous holder's audience, and an
* operator looking at only the live list would read a burned name as simply absent.
*/
async listBurned () { return Object.values((await this._read()).burned) }
/** Mint a single-use invite. The operator's act; only meaningful when the policy is `invite`. */
async invite () {
const state = await this._read()
const token = randomToken()
state.invites[token] = { mintedAt: Date.now() }
await this._write(state)
return token
}
/**
* Claim a name.
*
* ⛔ `root` is REQUIRED under both policies. Open registration relaxes CONSENT, never CONTROL —
* a bridge that let anyone claim any name without an identity behind it would be handing out
* public addresses on the operator's domain to nobody in particular.
*/
async claim ({ username, root, feedKey = null, invite = null } = {}) {
if (badName(username)) return { ok: false, error: 'bad username' }
if (typeof root !== 'string' || !HEX64.test(root)) return { ok: false, error: 'identity root required' }
const state = await this._read()
const existing = state.names[username]
if (existing) {
// ⛔ THE HOLDER ASKING AGAIN IS NOT A SQUATTER. Control was proved before this call (admit.js
// verifies the chain proof first), so a matching root IS the holder — and answering `taken` to
// the person who holds it is how a name becomes unrecoverable. Measured on a handset: the name
// was claimed, the device's local record never got written (admit.js's 202 omitted `acct`), and
// from then on re-claiming said "taken" while the release page refused to open because it needs
// the record that was never written. Held, invisible, and no way back from the device.
//
// ⛔ THE GENERATION MUST NOT MOVE. It is what makes a REUSED name a different actor, so bumping
// it for the same holder would orphan every object id already federated under the old one —
// the impersonation-by-name-reuse guard turned on the very person it protects.
if (root && existing.root === String(root).toLowerCase()) {
// ⚠ The feed key is refreshed, because a person can hold one name across a replaced device
// and the bridge must hold the feed it was LAST told to hold.
if (feedKey && existing.feedKey !== feedKey) {
existing.feedKey = feedKey
await this._write(state)
}
return { ok: true, username: existing.username, generation: existing.generation }
}
return { ok: false, error: 'taken' }
}
if (this.policy !== 'open') {
if (!invite || !state.invites[invite]) return { ok: false, error: 'invite required' }
delete state.invites[invite] // ⛔ single use, consumed before the claim can fail on anything else
}
// ⛔ The generation is what makes a reused name a DIFFERENT actor. It counts every holder this
// name has ever had, so ids derived from it never collide with a previous holder's.
const prior = state.burned[username]
const generation = (prior ? prior.generation : 0) + 1
// ⛔ THE FEED KEY IS PERSISTED. Without it a restart re-admits the name with nothing to hold —
// the actor exists and serves nothing, which is worse than not serving at all because it looks
// registered. Measured: the first deploy logged `admitted … held:false` for exactly this.
state.names[username] = { username, root: root.toLowerCase(), feedKey: feedKey || null, generation, registeredAt: Date.now() }
await this._write(state)
return { ok: true, username, generation }
}
/**
* Release a name — by its holder, or by the operator.
*
* ⛔ TWO AUTHORITIES, deliberately. The holder may leave; the operator may reclaim, because the
* names live on their domain and a bridge without an arbiter has permanent squatters. That is the
* gap #59 §3 recorded in favela's registry, which cannot be fixed there for the same reason it
* can be here: a commons has no owner and a bridge does.
*/
async release (username, { root = null, operator = false } = {}) {
if (badName(username)) return { ok: false, error: 'bad username' }
const state = await this._read()
const held = state.names[username]
if (!held) return { ok: false, error: 'not registered' }
if (!operator && (!root || held.root !== String(root).toLowerCase())) {
return { ok: false, error: 'not yours to release' }
}
delete state.names[username]
// Remembered, not forgotten: a federated peer may still hold references into what this name
// addressed, and the next holder must not inherit them.
state.burned[username] = { ...held, releasedAt: Date.now(), by: operator ? 'operator' : 'holder' }
await this._write(state)
return { ok: true, username }
}
}
/**
* ⛔ A CSPRNG, because on an `invite` bridge this token IS the admission gate.
*
* This was four draws from `Math.random()`, which is not cryptographically secure and is seeded and
* advanced predictably — so the one secret standing between a stranger and a name on somebody's
* domain was guessable. bare-crypto was already a dependency of this package.
*/
function randomToken () {
return b4a.toString(crypto.randomBytes(16), 'hex')
}
export class Registrations extends BaseRegistrations {
constructor ({ path, policy } = {}) {
super({ policy })
if (!path) throw new Error('Registrations: a path is required')
this._path = path
this._state = null
}
async _read () {
if (this._state) return this._state
try {
const raw = JSON.parse(fs.readFileSync(this._path, 'utf8'))
this._state = { names: raw.names || {}, burned: raw.burned || {}, invites: raw.invites || {} }
} catch {
// Absent or corrupt: start empty rather than refusing to boot, matching followers.js. ⚠ On a
// bridge this is louder than it looks — an unreadable registry means every registered actor
// 404s until it is restored, so the file is written atomically below.
this._state = { names: {}, burned: {}, invites: {} }
}
return this._state
}
async _write (state) {
this._state = state
fs.mkdirSync(dirname(this._path), { recursive: true })
const tmp = this._path + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(state, null, 2))
fs.renameSync(tmp, this._path)
}
}
/** Same interface, no disk. For tests. */
export class MemoryRegistrations extends BaseRegistrations {
constructor (opts) { super(opts); this._state = { names: {}, burned: {}, invites: {} } }
async _read () { return this._state }
async _write (state) { this._state = state }
}
export default { Registrations, MemoryRegistrations }