OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / namespace.js

947 lines · javascript ref:
// A namespace — human names that resolve to feeds.
//
// ── What this is, in one line ─────────────────────────────────────────────────────────────────
//
// `NAMING.md`: **hashes are unique, names are not global, and there are global namespaces you can
// subscribe to.** A namespace is not an authority — it is a SUBSCRIPTION. You join it the way you
// join anything else here, and its names become resolvable *to you*.
//
// ⛔ Two people subscribed to different namespaces resolve the same string to different feeds, and
// that is correct rather than a conflict. Zooko's triangle is not being escaped: we give up global
// uniqueness, and the compensation is that a name is always qualified by a namespace you can point
// at.
//
// ── Why this is a separate log from the feed ──────────────────────────────────────────────────
//
// A feed's `profile/self` carries a SELF-LABEL: what a feed calls itself, claimed from nobody and
// unique to nobody. This is the other thing — a name CLAIMED in a shared log, unique within it,
// first-claim-wins by linearization. They coexist deliberately, and a client resolves in order:
//
//     handle (this)  →  self-label (profile record)  →  key (always shown)
//
// ── ⛔ The default is CONFIGURATION, never a root ─────────────────────────────────────────────
//
// Shipping a default namespace makes us a convenient starting point, never an authoritative one.
// `NAMING.md` §8 #2 names the hazard exactly: *a default that cannot be changed is a root.* So the
// key is an option, several namespaces coexist, and nobody has to agree.
//
// ── What we give up, stated ───────────────────────────────────────────────────────────────────
//
// ⚠ First-claim-wins plus reclaimable releases means a name CAN change hands. A client that has
// displayed a name must surface a reassignment — the same duty as a key-change warning in a
// messenger. Silently re-pointing a familiar name at a new person is the one failure this design
// can actually cause, so `resolve()` returns the claim's identity, not merely a feed key.

import Autobee from 'autobee'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import { readAttested, signAttested } from './attestation.js'

const SEP = '/'
const CLAIM = 'name'
// Next discriminator per name. A COUNTER, not a scan: apply must assign the number deterministically
// and in O(1), and every peer replaying the same ops must reach the same assignment.
const COUNT = 'count'
// What a given identity already holds for a given name. Replay protection AND the rule that one
// person does not accumulate `alice0`, `alice1`, `alice2` — see `applyOps`.
const HELD = 'held'

// ── Who CARRIES the log ───────────────────────────────────────────────────────────────────────
//
// ⛔ Two different privileges, and conflating them turns a registry into an authority:
//
//   ADMIT   who carries the log — sort weight, ordering, writability. Granted.
//   CLAIM   who may take a name — ANYONE, for ever, by the forced number. Ungrantable.
//
// NAMING.md §4 says namespace governance is room governance, "who may claim = who may write". We
// take only half of that deliberately: this namespace stays open, and only WEIGHT is privileged.
//
// Why it is needed at all, MEASURED against autobee 2.1.1 (`probe-weight.mjs`, 2026-09-07):
// `host.ackWriter` records a writer's length so its optimistic block sticks and grants NOTHING —
// `maxWeight` unchanged, `isAdded` false, system record still null. `Writer.isIndexer` is
// `weight >= 2` and `weight` is the `maxWeight` capability only `host.addWriter` grants. So before
// this, every node that joined someone else's namespace was a weightless appender for ever and the
// founder carried the whole log alone.
//
// Reserved prefixes: control data, never reachable by a claim (names live under `name/`).
const ADMIN = '__admins__'
const WRITER = '__writers__'
const REVOKED = '__revoked__'
// A node asking to carry the log. The doorbell shape `feed.js` already uses: the one op a
// not-yet-writer may usefully send, and the thing an admit must point at.
const REQUEST = '__requests__'
// Invites. The log holds the HASH; the token is printed once and never stored.
//
// ⛔ An invite grants NOTHING — it cannot. Redeeming rides the joiner's own weightless block, and a
// grant is capped at the carrying block's weight. What the token does is authorise the INVITER'S
// node to answer a request unattended. The admit is still carried by an admin.
//
// ⚠ No expiry, revocation only. A deadline would need a clock inside apply, which is replayed on
// every peer — the same decision `browsers.js` reached.
const INVITE = '__invites__'
// A single marker, so "has this namespace been founded" is one get rather than a scan. The same
// reason `feed.js` keeps `__founder__` instead of asking whether the roster is empty.
const FOUNDED = '__founded__'

// ⛔ Ops carry a version. A claim that does not say `v: 2` is refused outright: there is no
// unattested claim and no mode anyone is in by accident, the same shape `feed.js` uses for
// admission.
//
// ⚠ This was justified as "affordable because the fleet's namespaces are empty — measured
// 2026-09-07 on all three hosts". That measurement was WRONG: ashatron was unreachable when it was
// taken, and it held `ashatron0`. The claim happened to be v:2 (made 22 minutes after attestation
// shipped) so nothing was lost, but the reasoning was luck rather than evidence.
//
// Once legacy claims DO exist, pgh's rule applies instead: legacy ops must keep applying or replay
// resurrects released names (`pgh/lib/name-registry.js`, OP_VERSION_SIGNED).
const OP_VERSION_SIGNED = 2
const KNOWN_OPS = new Set(['claim', 'release', 'found', 'admin', 'admit', 'revoke', 'request',
  'invite', 'uninvite'])
const enc = (v) => b4a.from(JSON.stringify(v), 'utf8')
const dec = (b) => (b == null ? null : JSON.parse(b4a.toString(b, 'utf8')))
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s)
// A single path segment: never empty, never a separator, never a traversal — the same guard
// `feed.js` uses, for the same reason (these become view keys).
const isSeg = (s) => typeof s === 'string' && s.length > 0 && !s.includes(SEP) && s !== '.' && s !== '..'

/**
 * Names are lower-cased and restricted to a small, unambiguous alphabet.
 *
 * ⛔ Normalised on the way IN and on the way OUT, so `Alice` and `alice` cannot be two claims. A
 * namespace whose names differ only by case or by a homoglyph is a namespace built for
 * impersonation.
 *
 * ⚠ Deliberately NOT unicode. Confusable-character handling is a real problem with a real
 * literature, and a half-done version of it is worse than an alphabet a human can verify by eye.
 */
export function normalize (name) {
  if (typeof name !== 'string') return null
  const n = name.trim().toLowerCase()
  // ⛔ A name may NOT END IN A DIGIT. The handle is the name with its number stuck straight on the
  // end — `alice` + `0` = `alice0` — so if a name could end in a digit, `alice12` would be both
  // "alice number 12" and "alice1 number 2" and nothing could tell them apart.
  //
  // That rule is what buys a separator-free handle. It costs names like `web3`, which is a real
  // cost and a deliberate one.
  if (!/^[a-z][a-z0-9._-]{0,30}[a-z._-]$/.test(n)) return null
  if (n.includes(SEP)) return null
  return n
}

const claimKey = (name, number) => CLAIM + SEP + name + SEP + number
const countKey = (name) => COUNT + SEP + name
const heldKey = (root, name) => HELD + SEP + root + SEP + name
const adminKey = (root) => ADMIN + SEP + root
const writerKey = (writer) => WRITER + SEP + writer
const revokedKey = (device) => REVOKED + SEP + device
const requestKey = (writer) => REQUEST + SEP + writer
const inviteKey = (hash) => INVITE + SEP + hash
const tokenHash = (token) => {
  try { return b4a.toString(crypto.data(b4a.from(String(token), 'hex')), 'hex') } catch { return null }
}

/**
 * The bytes an op's proof covers: every field EXCEPT the proof itself, keys sorted, JSON.
 *
 * ⛔ Sorted and total, so the signature commits to the WHOLE op — name, feed, namespace, anchor,
 * timestamp — rather than to a hand-picked subset. Swapping the feed key after signing produces
 * different bytes and the proof stops verifying. The same shape as pgh's `canonicalOpBytes`
 * (`pgh/lib/name-registry.js`), and it is why favela needs one primitive where keet needs two: keet
 * attests `encodeReservation({ username })`, which covers the username alone, so binding the core
 * key takes a SECOND proof (`keet-core/lib/core/invitations/index.js:114`).
 *
 * ⚠ Field ORDER cannot come from the object: two peers building the same op with keys inserted in
 * a different order would produce different bytes and disagree about a valid signature. Sorting is
 * what makes it canonical rather than merely deterministic-on-one-machine.
 */
export function canonicalOpBytes (op) {
  const rest = {}
  for (const k of Object.keys(op)) if (k !== 'proof') rest[k] = op[k]
  const ordered = {}
  for (const k of Object.keys(rest).sort()) ordered[k] = rest[k]
  return b4a.from(JSON.stringify(ordered), 'utf8')
}

/** Attach a device's proof over an op's canonical bytes. The inverse of what apply checks. */
function sign (op, cred) {
  return { ...op, proof: signAttested(canonicalOpBytes(op), cred) }
}

/**
 * Is this op authorised, and by whom?
 *
 * Returns the verified `{ root, device, chain }` or null. Pure, total, no clock — the properties
 * `attestation.js` exists to guarantee, and the reason this can run inside apply at all.
 *
 * ⛔ `nsHex` is checked because the proof alone does not name a namespace. Without it a claim signed
 * for one namespace lifts verbatim into any other, minting your name somewhere you never joined.
 */
function authorise (op, nsHex) {
  if (op.v !== OP_VERSION_SIGNED) return null
  if (!isKeyHex(op.root)) return null
  if (op.ns !== nsHex) return null
  return readAttested(op.proof, canonicalOpBytes(op), op.root)
}


/**
 * A handle is the name with its number on the end — `alice0`, `alice1`, `alice2`.
 *
 * ⛔ The NAME is not unique; the handle is. That is the point: a taken name never blocks anyone, so
 * there is nothing to squat and no race to win. It also makes changing your name ordinary — release
 * and claim again — rather than a transfer of something scarce.
 *
 * ⛔ There is no bare `alice`. The FIRST claimant is `alice0`, so nobody holds a name without a
 * number and no handle is privileged over another by being older.
 *
 * ⚠ Numbers are NEVER reused. A released `alice0` stays retired rather than passing to the next
 * claimant, because a recycled handle is precisely the "familiar name now denotes someone else"
 * failure NAMING.md warns about.
 */
export function parseHandle (handle) {
  if (typeof handle !== 'string') return null
  const h = handle.trim().toLowerCase()
  const m = /^(.*?)([0-9]+)$/.exec(h)
  if (!m) return null
  // No leading zeros: one handle, one spelling. `alice00` is not `alice0`.
  if (m[2].length > 1 && m[2][0] === '0') return null
  const name = normalize(m[1])
  if (!name) return null
  return { name, number: Number(m[2]) }
}

export const formatHandle = (name, number) => String(name) + String(number)

/**
 * The { get, set } surface apply wants, over ONE hyperbee2 write batch.
 *
 * ⛔ autobee's apply receives a hyperbee2, not a Hyperbee: raw Buffers, no key or value encoding,
 * `write()` rather than `batch()`, and the write handle has NO get. So reads go to the bee and a
 * pending overlay carries writes made earlier in the same batch — without it, two claims on one
 * name in a single batch would both see "free" and the second would overwrite the first.
 *
 * The same shape feed-base.js uses, for the same reasons.
 */
function batchView (bee, batch) {
  const pending = new Map()
  return {
    async get (key) {
      if (pending.has(key)) return pending.get(key)
      const node = await bee.get(b4a.from(key, 'utf8'))
      return node ? dec(node.value) : null
    },
    async set (key, value) {
      pending.set(key, value)
      batch.tryPut(b4a.from(key, 'utf8'), enc(value))
    },
    /**
     * Every entry under `prefix`, INCLUDING writes made earlier in this same batch.
     *
     * ⛔ The pending merge is the whole point. A scan straight against the bee cannot see a `set`
     * from an earlier op of the same batch, so a writer admitted and revoked in one batch would
     * survive the revocation. That is exactly the defect `hyper-identity2`'s BatchView was written
     * for (#63 §3.2), and a contract test would not have caught it there either.
     *
     * ⚠ Used ONLY over `__writers__`, whose size is the set of machines carrying the log — small
     * and bounded. Never over names: that scan is O(everyone) and would run inside apply.
     */
    async scan (prefix) {
      const out = new Map()
      const p = b4a.from(prefix, 'utf8')
      for await (const node of bee.createReadStream({ gte: p, lt: b4a.concat([p, b4a.from([0xff])]) })) {
        out.set(b4a.toString(node.key, 'utf8'), dec(node.value))
      }
      for (const [key, value] of pending) if (key.startsWith(prefix)) out.set(key, value)
      return [...out].map(([key, value]) => ({ key, value })).filter((e) => e.value != null)
    }
  }
}

/**
 * apply — deterministic and total, like every apply here.
 *
 * First-claim-wins is a property of the LINEARIZATION, not of a check-then-write: two peers racing
 * one name both append, the log picks an order, and every peer replays that same order. There is no
 * window in which both succeed.
 */
/**
 * Is this device retired, or does it descend from one that is?
 *
 * ⛔ Transitive, because a device can mint descendants from its own keypair with no mnemonic — so a
 * compromised device HAS descendants and revoking it has to reach them or it reaches nothing. The
 * proof carries the whole chain, which is why the ancestry is expressible here at all.
 */
async function isRetired (b, proof) {
  if (await b.get(revokedKey(proof.device))) return true
  for (const ancestor of proof.chain) {
    if (await b.get(revokedKey(ancestor))) return true
  }
  return false
}

/**
 * The signer, if this op is authorised AND its identity is an admin in good standing.
 *
 * ⛔ Revocation is tested BEFORE the grant is read. Self-service admission means a retired device
 * still holds a perfectly valid proof, so checking the admin list first would make revocation
 * decorative — the ordering `feed.js:167` already pays for.
 */
async function adminSigner (b, op, nsHex) {
  const proof = authorise(op, nsHex)
  if (!proof) return null
  if (await isRetired(b, proof)) return null
  if (!(await b.get(adminKey(proof.root)))) return null
  return proof
}

async function applyOps (batch, view, host, nsHex) {
  const write = view.write()
  const b = batchView(view, write)

  for (const node of batch) {
    let op
    try { op = dec(node.value) } catch { continue } // a malformed block is a no-op, deterministically
    if (!op || typeof op !== 'object') continue
    // ⛔ node.key, not node.from.key — autobee attributes on the node itself.
    const writer = node.key ? hex(node.key) : null
    if (!writer) continue

    // An open namespace takes writes from peers who were never admitted, so the optimistic block
    // has to be acked or it never sticks. Acked only for the ops that exist; anything else is
    // ignored AND unacked, so a stranger cannot park arbitrary blocks in the log.
    //
    // ⛔ `admit` MUST be in this set. The node sending one is by definition not yet a writer — that
    // is what it is asking to become — so without an ack its block never sticks and admission can
    // never happen at all. ⚠ An ack is not admission: it grants no weight and no writability
    // (measured — see the ADMIN block above).
    if (KNOWN_OPS.has(op.t)) {
      try { host.ackWriter(node.key) } catch {}
    }

    if (op.t === 'claim') {
      const name = normalize(op.name)
      if (!name || !isKeyHex(op.feed)) continue

      // ⛔ The claim must PROVE who is making it. Everything the op says is inside the signed bytes,
      // so a stranger cannot lift a valid claim and re-aim it at another feed, another name or
      // another namespace — any of those edits changes the bytes and the proof stops verifying.
      //
      // ⚠ What this CANNOT prove is that `root` owns `feed`. The namespace is a separate log with
      // no access to the feed's `__founder__`, so a stranger may still claim a name pointing at
      // someone else's feed — under THEIR OWN root, which is recorded here and is what makes the
      // lie detectable. That half is checked where the feed is actually held; the claim carries
      // `root` precisely so it can be.
      const proof = authorise(op, nsHex)
      if (!proof) continue

      // ⛔ One identity, one live claim per name. This is replay protection — re-appending a
      // captured claim op mints nothing — and it is also the rule that stops one person quietly
      // accumulating `alice0`, `alice1`, `alice2` and re-creating the scarcity the number exists
      // to abolish.
      const held = await b.get(heldKey(proof.root, name))
      if (held && held.live) continue

      // ⛔ APPLY assigns the number, from a counter it owns. A client that picked its own would be
      // racing every other client for the same one; here two peers claiming `alice` in the same
      // batch get 0 and 1, in whatever order the log settled, on every peer identically.
      // Starts at 0, so the first claimant is `alice0` — nobody gets a bare name.
      const stored = await b.get(countKey(name))
      const next = typeof stored === 'number' ? stored : 0
      await b.set(countKey(name), next + 1)
      await b.set(heldKey(proof.root, name), { number: next, live: true })
      await b.set(claimKey(name, next), {
        feed: op.feed.toLowerCase(),
        // ⛔ The ANCHOR is the identity ROOT, not the device and not the writer core. pgh shipped a
        // device key here and had to add `reanchor --all --force` to repair it; the writer key this
        // used to store is a step further down still — manifest-derived, no secret half, it can
        // never sign, so nothing could ever be proven about it later.
        root: proof.root,
        // The device that actually signed, kept for revocation: a chain is made of device keys, so
        // ancestry is only expressible over them.
        device: proof.device,
        writer,
        // ⚠ From the OP, never from a clock in here: apply is replayed per peer, and a timestamp
        // read here would make two peers write different bytes for the same op.
        at: typeof op.at === 'number' ? op.at : 0,
        released: false
      })
      continue
    }

    // ── who carries the log ─────────────────────────────────────────────────────────────────

    // Found the namespace: the creator's identity ROOT becomes the first admin.
    //
    // ⛔ ONE marker, checked first, so a second founding op changes nothing. The window this cannot
    // close is before the FIRST op ever lands — which is why `found()` runs at creation, before the
    // key has been handed to anyone. A namespace whose key you were given is already founded.
    //
    // ⚠ The op must reach the log NON-optimistically: "an optimistic writer cannot be genesis"
    // (autobee/lib/writers.js:529), so an optimistic founding op is never processed and the
    // namespace comes up with no admin and no way to add one.
    if (op.t === 'found') {
      if (await b.get(FOUNDED)) continue
      const proof = authorise(op, nsHex)
      if (!proof) continue
      await b.set(FOUNDED, { root: proof.root, at: typeof op.at === 'number' ? op.at : 0 })
      await b.set(adminKey(proof.root), { by: proof.root, device: proof.device, founding: true })
      continue
    }

    // Grant admin to another identity. An admin's act, and anchored to ROOTS on both sides: one
    // person's two devices are two writers, so a device-scoped list could never say "this person".
    if (op.t === 'admin') {
      if (!isKeyHex(op.grant)) continue
      const proof = await adminSigner(b, op, nsHex)
      if (!proof) continue
      await b.set(adminKey(op.grant.toLowerCase()), { by: proof.root, device: proof.device })
      continue
    }

    // Admit a writer core, so this node carries the log rather than appealing to it.
    //
    // ⛔ SELF-SERVICE, and the writer key is inside the SIGNED BYTES. `host.addWriter` wants an
    // autobee writer core key — manifest-derived, no secret half, it can never sign (#63 §2.2) — so
    // the node proves itself with its DEVICE credential and names its own core. Nothing is carried
    // back to the founder by hand, and swapping the key after signing breaks the proof.
    // Ask to carry the log. The one useful op a not-yet-writer can send, and it grants NOTHING —
    // it records that a writer core wants in, and whose device stands behind it.
    //
    // ⛔ A request must exist before an admit can name it, so admission takes TWO acts by TWO
    // parties: the joiner asks with its own device, an admin honours it. Exactly the rule
    // `applyListFeed` enforces with knocks, and for the same reason — consent becomes structural
    // rather than a promise the inviter makes.
    if (op.t === 'invite') {
      if (!isSeg(op.hash)) continue
      const proof = await adminSigner(b, op, nsHex)
      if (!proof) continue
      if (await b.get(inviteKey(op.hash))) continue // never re-open a spent one
      await b.set(inviteKey(op.hash), { by: proof.root, used: false })
      continue
    }

    if (op.t === 'uninvite') {
      if (!isSeg(op.hash)) continue
      const proof = await adminSigner(b, op, nsHex)
      if (!proof) continue
      const inv = await b.get(inviteKey(op.hash))
      if (!inv || inv.used) continue
      await b.set(inviteKey(op.hash), { ...inv, withdrawn: true, used: true, by: proof.root })
      continue
    }

    if (op.t === 'request') {
      if (!isKeyHex(op.writer)) continue
      const proof = authorise(op, nsHex)
      if (!proof) continue
      if (await isRetired(b, proof)) continue
      // ⛔ The token is spent INSIDE apply, so two nodes racing one invite cannot both win — the
      // linearization decides and there is no window. A bad token is not an error: anyone may ask,
      // the ask simply is not vouched for and no admin's node will answer it unattended.
      let invited = false
      if (op.token !== undefined) {
        const h = tokenHash(op.token)
        const inv = h && await b.get(inviteKey(h))
        if (inv && !inv.used) {
          await b.set(inviteKey(h), { ...inv, used: true, by: inv.by, redeemer: proof.root })
          invited = true
        }
      }
      await b.set(requestKey(op.writer.toLowerCase()), { root: proof.root, device: proof.device, invited })
      continue
    }

    // Admit a writer core, so it carries the log.
    //
    // ⛔⛔ THIS OP MUST BE CARRIED BY A WRITER THAT ALREADY HAS WEIGHT, and apply cannot check that.
    // `system.addWriter` computes `effective = coord && carrier < weight ? max(carrier, 0) : weight`
    // (autobee/lib/system.js:64) — `carrier` is the weight of the BLOCK carrying the grant. So an
    // admit ridden in on a weightless optimistic block is capped to 0: the grant appears to succeed,
    // every peer agrees on the view, and the writer silently gets nothing. MEASURED 2026-09-07
    // (`probe-optimistic.mjs`): self-admission gave maxWeight 0 on both peers; the same admit
    // carried by the founder gave 2.
    //
    // ⇒ Weight is only ever conferred DOWNWARD. A node cannot bootstrap its own, which is why
    // `admitWriter` refuses locally rather than appending something that would be quietly inert.
    if (op.t === 'admit') {
      if (!isKeyHex(op.writer)) continue
      const proof = await adminSigner(b, op, nsHex)
      if (!proof) continue
      const writer = op.writer.toLowerCase()
      const asked = await b.get(requestKey(writer))
      // An admin cannot conscript a machine that never asked, and cannot admit one whose device was
      // retired between asking and being answered.
      if (!asked || asked.removed) continue
      if (await b.get(revokedKey(asked.device))) continue
      // ⚠ Wrapped: apply must be TOTAL. A throw mid-replay wedges the base and the op that caused
      // it is already durable, so there is no version of "fix it and retry".
      try { host.addWriter(b4a.from(writer, 'hex'), { isIndexer: true }) } catch {}
      await b.set(writerKey(writer), { root: asked.root, device: asked.device, by: proof.root })
      continue
    }

    // Retire a device — a TOMBSTONE, because absence is ambiguous in a replicated log.
    //
    // ⛔ It reaches what the device already holds. A revocation that only refused FUTURE admits
    // would leave a compromised machine carrying the log for ever, which is revocation in name.
    if (op.t === 'revoke') {
      if (!isSeg(op.device)) continue
      const proof = await adminSigner(b, op, nsHex)
      if (!proof) continue
      const device = op.device.toLowerCase()
      await b.set(revokedKey(device), { by: proof.device, root: proof.root })
      // ⚠ A scan of the WRITER prefix only — the set of machines carrying the log, which is small
      // and bounded. Never a scan of names.
      for (const entry of await b.scan(WRITER + SEP)) {
        if (!entry.value || entry.value.device !== device) continue
        const w = entry.key.slice(WRITER.length + 1)
        try { host.removeWriter(b4a.from(w, 'hex')) } catch {}
        await b.set(entry.key, { ...entry.value, removed: true })
      }
      // ⚠ And its outstanding asks, or a retired device is re-admitted from a stale request the
      // moment any admin answers it.
      for (const entry of await b.scan(REQUEST + SEP)) {
        if (!entry.value || entry.value.device !== device) continue
        await b.set(entry.key, { ...entry.value, removed: true })
      }
      continue
    }

    if (op.t === 'release') {
      const h = parseHandle(op.handle)
      if (!h) continue
      const name = h.name
      const existing = await b.get(claimKey(name, h.number))
      if (!existing || existing.released) continue

      // ⛔ Only the IDENTITY that claimed it may release it — proven, not asserted. Anything less
      // would let a stranger free someone's name and take it.
      //
      // ★ This is what replaced the writer comparison that used to stand here, and it closes the
      // limitation written beside it: a name claimed on one machine can now be released from any
      // other device of the same person, because the anchor is the root and every device chains to
      // it. The old check could not express that — one identity's two devices are two writers.
      const proof = authorise(op, nsHex)
      if (!proof) continue
      if (existing.root !== proof.root) continue
      // A TOMBSTONE, and the name becomes reclaimable — NAMING.md says released names return to
      // the pool. That is also why a client must surface a reassignment.
      // ⚠ The number is retired, not freed — the counter never goes backwards.
      await b.set(claimKey(name, h.number), { ...existing, released: true, releasedAt: op.at || 0 })
      // ⛔ And the identity is free to claim this name again — which is what makes a rename ordinary
      // rather than a transfer. Without this the one-live-claim-per-name rule would turn every
      // release into a permanent ban on your own name.
      await b.set(heldKey(proof.root, name), { number: h.number, live: false })
      continue
    }
  }

  await write.flush()
}

export class Namespace {
  /**
   * @param store    a Corestore NAMESPACE of its own — two Autobees sharing one derive the same
   *                 local writer and deadlock rather than erroring.
   * @param key      the namespace to subscribe to; null creates one.
   */
  constructor (store, key = null, opts = {}) {
    this.base = new Autobee(store, key || null, {
      // ⛔ apply is closed over THIS namespace's own key, so an op has to name the namespace it was
      // signed for. `base.key` is fixed by `ready()` and identical on every peer replaying this
      // log, so reading it here costs no determinism.
      apply: (batch, view, host) => applyOps(batch, view, host, hex(this.base.key)),
      // ⛔ Open by construction. A namespace anyone can claim in needs writes from peers who were
      // never admitted, which is what optimistic append is for — the same mechanism the doorbell
      // uses. Governance of a CLOSED namespace is room roles (NAMING.md §4); this is the open one.
      optimistic: true,
      fastForward: false
    })
    this._label = opts.label || null
  }

  async ready () { await this.base.ready(); return this }
  get key () { return this.base.key }
  get discoveryKey () { return this.base.discoveryKey }
  async update () { await this.base.update() }
  replicate (isInitiatorOrStream) { return this.base.replicate(isInitiatorOrStream) }
  async close () { await this.base.close() }

  /**
   * Claim a name for a feed. The namespace assigns the number.
   *
   * Appended optimistically — a subscriber is not a writer. The handle is not known until the log
   * settles, so this returns nothing and the caller reads it back with `handleFor`.
   */
  async claim (name, feedKey, cred) {
    const op = this.claimOp(name, feedKey, cred)
    await this.base.append(enc(op), { optimistic: true })
    return op.name
  }

  /**
   * The signed op `claim` appends, without appending it.
   *
   * ⛔ Exposed so a test can drive the REAL op — tamper with one field, or replay it into another
   * namespace — rather than a hand-built lookalike. A test that reconstructs the op is testing its
   * own reconstruction; both of these tests passed for the wrong reason until they used this.
   */
  claimOp (name, feedKey, cred) {
    const n = normalize(name)
    if (!n) throw new Error('a name is 2-32 characters of a-z, 0-9, dot, dash or underscore, ' +
      'and may not END in a digit — the number goes there')
    return sign({
      t: 'claim',
      v: OP_VERSION_SIGNED,
      name: n,
      feed: hex(feedKey).toLowerCase(),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)
  }

  /**
   * Give up a handle. Only the identity that claimed it may — from ANY of its devices.
   */
  async release (handle, cred) {
    await this.base.append(enc(this.releaseOp(handle, cred)), { optimistic: true })
  }

  /**
   * The signed op `release` appends, without appending it. Exposed for the same reason `claimOp`
   * is: a test that rebuilds the op is testing its own reconstruction, and a hostile op has to be
   * driven into the REAL base to prove apply refuses it.
   */
  releaseOp (handle, cred) {
    const h = parseHandle(handle)
    if (!h) throw new Error('a handle is a name with its number on the end, e.g. alice0')
    return sign({
      t: 'release',
      v: OP_VERSION_SIGNED,
      handle: formatHandle(h.name, h.number),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)
  }

  // ── who carries the log ──────────────────────────────────────────────────────────────────────

  /**
   * Found this namespace: `cred`'s identity becomes its first admin.
   *
   * ⛔ Call it at CREATION, before the key has been shared. The op is refused once an admin exists,
   * so the only window is before the first op lands — and that window is only closed by nobody else
   * holding the key yet.
   *
   * ⛔ Appended NON-optimistically, deliberately: an optimistic writer cannot be genesis
   * (autobee/lib/writers.js:529), so an optimistic founding op is never processed at all. It throws
   * "Not writable" if this node is not the genesis writer, which is the correct loud failure — you
   * cannot found a namespace somebody else created.
   */
  async found (cred) {
    await this.base.append(enc(this.foundOp(cred)))
  }

  foundOp (cred) {
    return sign({
      t: 'found',
      v: OP_VERSION_SIGNED,
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)
  }

  /** Grant admin to another IDENTITY. An admin's act. */
  async grantAdmin (rootHex, cred) {
    if (!isKeyHex(rootHex)) throw new Error('an admin is an identity root — 32 bytes of hex')
    await this.base.append(enc(sign({
      t: 'admin',
      v: OP_VERSION_SIGNED,
      grant: String(rootHex).toLowerCase(),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)), { optimistic: true })
  }

  /**
   * Ask to carry this namespace's log. Appended optimistically — a joiner is not a writer yet, and
   * asking is the one useful thing it can do. Grants nothing on its own.
   */
  async request (cred, token) {
    return this.requestWriter(hex(this.base.local.key), cred, token)
  }

  async requestWriter (writerHex, cred, token) {
    if (!isKeyHex(writerHex)) throw new Error('a writer is an autobee core key — 32 bytes of hex')
    const op = {
      t: 'request',
      v: OP_VERSION_SIGNED,
      writer: String(writerHex).toLowerCase(),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }
    // ⚠ The token travels in the clear, and that is fine: it is spent by the very op that carries
    // it. Same as a browser redeem.
    if (token !== undefined && token !== null) op.token = String(token)
    await this.base.append(enc(sign(op, cred)), { optimistic: true })
  }

  /**
   * Answer a request: admit that writer core so it carries the log.
   *
   * ⛔ Refuses unless THIS node already carries weight. `system.addWriter` caps the grant at the
   * weight of the block carrying it, so an admit appended by a weightless writer is silently inert —
   * it applies, every peer agrees, and the target gets maxWeight 0. There is no error anywhere,
   * which is the worst kind of failure, so this is the loud one.
   */
  async admitWriter (writerHex, cred) {
    if (!this.base.isIndexer) {
      throw new Error('this node carries no weight in this namespace, so admitting anyone would ' +
        'grant them nothing at all — weight is only ever conferred downward. Ask an admin to ' +
        'admit them, or be admitted yourself first.')
    }
    await this.base.append(enc(this.admitOp(writerHex, cred)), { optimistic: true })
  }

  /** Outstanding asks — `{ writer, root, device, invited }`. What an inviter answers. */
  async requests () {
    return (await this._prefix(REQUEST))
      .filter((e) => !e.value.removed)
      .map((e) => ({ writer: e.key, root: e.value.root, device: e.value.device, invited: !!e.value.invited }))
  }

  /**
   * Mint a single-use invite. Returns `{ token, hash }` — the TOKEN is printed once and never
   * stored anywhere; only its hash reaches the log.
   */
  async invite (cred) {
    const token = b4a.toString(crypto.randomBytes(32), 'hex')
    const hash = tokenHash(token)
    await this.base.append(enc(sign({
      t: 'invite',
      v: OP_VERSION_SIGNED,
      hash,
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)), { optimistic: true })
    return { token, hash }
  }

  /** Withdraw an unspent invite. Revocation stands in for expiry — apply has no clock. */
  async withdrawInvite (hash, cred) {
    if (!isSeg(hash)) throw new Error('withdrawing needs an invite hash')
    await this.base.append(enc(sign({
      t: 'uninvite',
      v: OP_VERSION_SIGNED,
      hash: String(hash).toLowerCase(),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)), { optimistic: true })
  }

  /** Invites minted here — `{ hash, used, withdrawn }`. The token is in none of them. */
  async invites () {
    return (await this._prefix(INVITE))
      .map((e) => ({ hash: e.key, used: !!e.value.used, withdrawn: !!e.value.withdrawn }))
  }

  /**
   * Answer every vouched-for ask that is not already a writer. Returns the writers admitted.
   *
   * ⛔ Only the INVITED ones. An uninvited ask still waits for a human, which is the entire point of
   * the token — otherwise this method would auto-admit any stranger who asked.
   */
  async answerInvited (cred) {
    if (!this.base.isIndexer) return []
    const done = new Set((await this.writers()).map((w) => w.writer))
    const out = []
    for (const r of await this.requests()) {
      if (!r.invited || done.has(r.writer)) continue
      try {
        await this.admitWriter(r.writer, cred)
        out.push(r.writer)
      } catch {}
    }
    if (out.length) await this.update()
    return out
  }

  admitOp (writerHex, cred) {
    if (!isKeyHex(writerHex)) throw new Error('a writer is an autobee core key — 32 bytes of hex')
    return sign({
      t: 'admit',
      v: OP_VERSION_SIGNED,
      writer: String(writerHex).toLowerCase(),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)
  }

  /**
   * Retire a DEVICE, and everything it carries.
   *
   * ⛔ The device, never the writer core: a chain is made of device keys — the only keys that can
   * sign — so ancestry is only expressible over them, and one revoke retires every core that device
   * admitted.
   */
  async revokeDevice (deviceHex, cred) {
    if (!isSeg(deviceHex)) throw new Error('revoking needs a device key')
    await this.base.append(enc(sign({
      t: 'revoke',
      v: OP_VERSION_SIGNED,
      device: String(deviceHex).toLowerCase(),
      ns: hex(this.base.key),
      root: this._root(cred),
      at: Date.now()
    }, cred)), { optimistic: true })
  }

  /** The identity roots that carry this namespace. */
  async admins () {
    return (await this._prefix(ADMIN)).map((e) => e.key)
  }

  /** The writer cores admitted here. Removed ones are omitted — retired, not present. */
  async writers () {
    return (await this._prefix(WRITER))
      .filter((e) => !e.value.removed)
      .map((e) => ({ writer: e.key, root: e.value.root, device: e.value.device }))
  }

  /** Retired device keys. Kept visible: a revocation nobody can see is not evidence of anything. */
  async revokedDevices () {
    return (await this._prefix(REVOKED)).map((e) => e.key)
  }

  /** Has this namespace been founded, and by whom? */
  async founder () {
    const node = await this._view().get(b4a.from(FOUNDED, 'utf8'))
    return node ? dec(node.value) : null
  }

  async _prefix (prefix) {
    const out = []
    const start = prefix + SEP
    for await (const node of this._view().createReadStream({
      gte: b4a.from(start, 'utf8'),
      lt: b4a.from(start + '\xff', 'utf8')
    })) {
      const value = dec(node.value)
      if (value == null) continue
      out.push({ key: b4a.toString(node.key, 'utf8').slice(start.length), value })
    }
    return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
  }

  /**
   * The identity root a credential resolves to.
   *
   * ⛔ Read back out of the credential's own proof rather than taken on trust from a caller, so the
   * `root` an op announces is always the one its proof will verify against. Refuses loudly: an
   * unlinked device has nothing to claim WITH, and a claim it could not sign would be dropped
   * silently by every peer's apply — a failure with no error, which is the worst kind.
   */
  _root (cred) {
    if (!cred) throw new Error('claiming a name needs this device\'s credential — run `favela link`')
    const read = readAttested(cred.proof, undefined)
    if (!read) throw new Error('this device\'s credential does not verify, so it cannot claim a name')
    return read.root
  }

  /**
   * Change a name: give up the old handle and take a new one.
   *
   * ⚠ Two ops, not one. The old handle is retired the moment the release lands, and the new number
   * is whatever the counter is at — so a rename is never a transfer, and there is a moment where
   * neither is current. That is honest for a log: an atomic rename would need a transaction the
   * substrate does not have.
   */
  async rename (oldHandle, newName, feedKey, cred) {
    await this.release(oldHandle, cred)
    return this.claim(newName, feedKey, cred)
  }

  /**
   * Resolve a handle to `{ handle, name, number, feed, at }`, or null.
   *
   * ⛔ Returns the whole claim rather than a bare feed key, so a caller can tell a REASSIGNMENT
   * from a steady handle. A client that has displayed a name owes the reader that difference.
   */
  async resolve (handle) {
    const h = parseHandle(handle)
    if (!h) return null
    const node = await this._view().get(b4a.from(claimKey(h.name, h.number), 'utf8'))
    const v = node && dec(node.value)
    if (!v || v.released) return null
    return {
      handle: formatHandle(h.name, h.number), name: h.name, number: h.number,
      feed: v.feed, at: v.at,
      // ⛔ The ANCHOR travels with every resolution. NAMING.md §3 resolves a name to an identity
      // root and only then to a room key — the caller needs the root to check this claim against
      // the feed's own `__founder__`, which is the half apply structurally cannot do.
      root: v.root, device: v.device
    }
  }

  /**
   * Everyone currently holding a given NAME — `alice` may be several people.
   *
   * ⛔ A bare name is deliberately not resolvable to one feed. NAMING.md §3.2: an unresolvable name
   * must fail visibly rather than have a namespace guessed for it, and picking "the first alice"
   * would be exactly that guess wearing a helpful face.
   */
  async candidates (name) {
    const n = normalize(name)
    if (!n) return []
    return (await this.list(n + SEP)).filter((c) => c.name === n)
  }

  /** The handle claimed for a feed, or null. */
  async handleFor (feedKey) {
    const feed = hex(feedKey).toLowerCase()
    for (const c of await this.list()) if (c.feed === feed) return c.handle
    return null
  }

  /** Live claims, in handle order. Released handles are omitted — they are retired, not present. */
  async list (prefix = '') {
    const out = []
    const start = CLAIM + SEP + (prefix || '')
    for await (const node of this._view().createReadStream({
      gte: b4a.from(start, 'utf8'),
      lt: b4a.from(CLAIM + SEP + (prefix || '') + '\xff', 'utf8')
    })) {
      const v = dec(node.value)
      if (!v || v.released) continue
      const rest = b4a.toString(node.key, 'utf8').slice(CLAIM.length + 1)
      const i = rest.lastIndexOf(SEP)
      if (i === -1) continue
      const name = rest.slice(0, i)
      const number = Number(rest.slice(i + 1))
      out.push({ handle: formatHandle(name, number), name, number, feed: v.feed, at: v.at, root: v.root })
    }
    return out.sort((a, b) => (a.handle < b.handle ? -1 : a.handle > b.handle ? 1 : 0))
  }

  /** Handles matching a query — what a search box calls. */
  async search (query, { limit = 25 } = {}) {
    const q = String(query == null ? '' : query).toLowerCase().trim()
    // Fail closed: an empty box is not a request for the whole namespace.
    if (!q) return []
    return (await this.list()).filter((c) => c.handle.includes(q) || c.name.includes(q)).slice(0, limit)
  }

  _view () { return this.base.view }
}

export default Namespace