// The people this bridge fronts.
//
// ⛔ ONE PROCESS, MANY ACTORS. Everything here was built around a single `config` tuple — host,
// username, did, key — read directly by webfinger, actor, outbox, inbox and followers. One instance
// per person is not an architecture, it is a hostname per person, and identity.md §6 says a bridge
// is a RESOURCE that users hold a grant on. Fronting whoever holds one is the shape.
//
// ⛔ THE INCUMBENT DOES NOT MOVE. Its actor is `https://<host>/ap/actor` and objects it already
// federated are `https://<host>/ap/objects/<rkey>`. A follower at another instance is following that
// exact id, and 22 objects are addressed under it. Per-user paths are ADDITIVE: new users get
// `/ap/users/<name>/…` and the flat paths stay bound to whoever was there first. Nothing already
// federated changes shape, which is the difference between adding users and a migration.
//
// ⚠ Resolution is by USERNAME, which must therefore be unique on this host — the same problem
// favela's namespace already solves with forced numbering. Nothing here mints one; admission does.
const RE_USERNAME = /^[a-z0-9][a-z0-9._-]{0,31}$/i
export class Users {
/**
* @param {object[]} entries { username, displayName?, summary?, alsoKnownAs?, primary?, ... }
* plus whatever the source and signer layers need.
* @param {string} host the public hostname; every id derives from it.
*/
constructor (entries, host) {
if (!host) throw new Error('Users: host is required — every actor id derives from it')
this.host = host
this._byName = new Map()
this._primary = null
for (const e of entries || []) {
if (!e || !RE_USERNAME.test(String(e.username || ''))) {
throw new Error('Users: invalid username ' + JSON.stringify(e && e.username))
}
const name = String(e.username).toLowerCase()
// ⛔ Refused, not overwritten. Two entries for one name means one of them silently never
// serves, and which one depends on config order.
if (this._byName.has(name)) throw new Error('Users: duplicate username ' + name)
const user = { ...e, username: name }
this._byName.set(name, user)
// The first entry, or an explicit `primary`, owns the flat paths.
if (!this._primary || e.primary) this._primary = user
}
}
/**
* Add a user after construction — a registration.
*
* ⛔ NEVER BECOMES PRIMARY. The primary owns the FLAT paths, and everything already federated is
* addressed under them. A name registered at runtime takes `/ap/users/<name>/` and nothing else
* moves; a registration that could seize the flat paths would re-home every existing object.
*
* Refused, not overwritten, exactly as at construction: two entries for one name means one of
* them silently never serves.
*/
add (entry) {
if (!entry || !RE_USERNAME.test(String(entry.username || ''))) {
throw new Error('Users: invalid username ' + JSON.stringify(entry && entry.username))
}
const name = String(entry.username).toLowerCase()
if (this._byName.has(name)) {
// ⛔ A RE-CLAIM IS NOT A DUPLICATE. The same holder claiming its own name again — the same non-empty root
// and the same feed — is how a peer fetches what a claim now returns (its observation feed, #82 I1 S3).
// Throwing here made admit.js answer "202 pending" to a user who was fully served. Anything else under a
// taken name is still refused.
const have = this._byName.get(name)
if (entry.root && have.root === entry.root && (have.feedKey || null) === (entry.feedKey || null)) return have
throw new Error('Users: duplicate username ' + name)
}
const user = { ...entry, username: name }
this._byName.set(name, user)
return user
}
/**
* Remove a user — a released name.
*
* ⛔ Refuses to remove the PRIMARY. The flat paths and every federated object id belong to it;
* removing it would 404 the incumbent's entire history on a registry operation.
*/
remove (username) {
const name = String(username || '').toLowerCase()
const user = this._byName.get(name)
if (!user) return false
if (user === this._primary) throw new Error('Users: the primary cannot be removed')
this._byName.delete(name)
return true
}
get size () { return this._byName.size }
get primary () { return this._primary }
all () { return [...this._byName.values()] }
get (username) {
return this._byName.get(String(username || '').toLowerCase()) || null
}
/**
* The ids for one user.
*
* ⛔ The primary keeps the FLAT paths. Everything already federated is addressed under them, and
* an actor that changes its id is a stranger to every server that already knows it.
*/
ids (user) {
if (!user) return null
const root = user === this._primary
? `https://${this.host}/ap`
: `https://${this.host}/ap/users/${user.username}`
return {
base: root,
actor: `${root}/actor`,
inbox: `${root}/inbox`,
outbox: `${root}/outbox`,
followers: `${root}/followers`,
following: `${root}/following`,
objects: `${root}/objects`
}
}
/**
* Which user, if any, a request path names — and the rest of the path after them.
*
* `/ap/actor` -> primary, '/actor'
* `/ap/users/alice/outbox` -> alice, '/outbox'
* `/ap/objects/<rkey>` -> primary, '/objects/<rkey>'
*/
route (path) {
const p = String(path || '')
if (!p.startsWith('/ap/')) return null
const rest = p.slice('/ap'.length) // '/actor', '/users/alice/outbox', …
if (rest.startsWith('/users/')) {
const after = rest.slice('/users/'.length)
const slash = after.indexOf('/')
const name = slash === -1 ? after : after.slice(0, slash)
const user = this.get(name)
if (!user) return null
return { user, rest: slash === -1 ? '/' : after.slice(slash) }
}
// ⚠ Only the PRIMARY answers a flat path. A second user reachable at /ap/actor would be
// answering for somebody else's id.
return this._primary ? { user: this._primary, rest } : null
}
/** WebFinger: `acct:<username>@<host>` -> the user, or null. Exact host match only. */
fromResource (resource) {
const m = /^acct:([^@]+)@(.+)$/.exec(String(resource || '').trim())
if (!m) return null
if (m[2].toLowerCase() !== this.host.toLowerCase()) return null
return this.get(m[1])
}
}
/**
* Build a registry from the single-actor config, so nothing changes for a node that has one.
*
* ⚠ This is the whole compatibility story: a one-entry registry whose sole member is primary
* produces byte-identical ids to the code that had no registry at all.
*/
export function usersFromConfig (config) {
return new Users([{
username: config.username,
displayName: config.displayName,
summary: config.summary,
alsoKnownAs: config.alsoKnownAs,
did: config.did || null,
favelaSocket: config.favelaSocket || null,
roomType: config.roomType || 'public',
primary: true
}], config.host)
}
export default { Users, usersFromConfig }