OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / users.js

173 lines · javascript ref:
// 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 }