"Who is a moderator" should change without a redeploy, but apps had to
hand-assemble the createAuthorityState actors map. Add resolveAuthorityRoster
(@bitgate/nostr): turn signed kind-30000 "role set" events into the
actor->roles map, ready to spread into createAuthorityState.
It honors ONLY sets signed by an authorized key -- the root, or (with
`signer: <role>`) an actor already granted that role, giving one-role-
delegates-another chains in a single ordered pass. It keeps the latest set
per publisher (kind 30000 is replaceable) and ignores anyone unauthorized,
which is the security property: the roster is only as trustworthy as the key
that signed it. Signature verification stays the caller's job (inject
@bitgate/verify upstream).
Covered by roster.test.js: root-only grants, unauthorized-ignored,
delegation, replaceable-latest-wins, and that the output feeds
createAuthorityState. README headless section shows the pattern.
Addresses the "moderator-set as data" item of the Tessera feedback
(docs/TODO.md) -- previously marked PARTIAL.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
BitGate read standard events but published a private vocabulary, making it an
island. NIP-32 (kind 1985) is the shared moderation vocabulary; BitGate now
reads and writes it. See ADR 0007.
The design keeps flexibility central, since the goal is a reusable building
block. A label is (namespace, value, target) and carries no built-in meaning —
"deny", "nsfw", and "MIT" are the same kind of object. What a label means is a
policy decision an application supplies via labelMapping { namespace,
denyValues, allowValues }. The codec itself is vocabulary-free; decodeLabels
returns every label regardless of namespace for generic use.
A matching label becomes a contribution authored by the labeller, gated
identically: it denies someone only if the labeller holds the capability in the
roster, exactly like a community source. An untrusted labeller's deny does
nothing, and labels fail closed without verification like any administrative
event.
- consume: ingestEvent maps kind 1985; loadLabels(labellers) fetches them
- emit: commands.publishLabel(target, value), gated by the same capability as
denying, so the shared form is no weaker than the private one
- the widget bundle re-exports the label API for the drop-in path
Validated against 40 real kind:1985 events from public relays (ISO-639-1
language labels among them): all decoded with targets and namespaces, zero
throws. 897 tests; cold gates green; audit clean.
A second adversarial pass over areas the first audit did not reach: codecs,
transport internals, and the verify/outbox/private-mute paths added since.
Reliability/CI (most urgent)
- The previous commit's Node-safety base-class fallback lost the HTMLElement
type, so defineElement() and this-typing broke. It only appeared with dist
cleared, so it passed locally with stale declarations and would fail CI from
a clean checkout. Annotated the fallback; cold lint/test/build now pass.
Security
- Relay URLs from a followed contact's kind:10002 flow straight to
new WebSocket(url). normalizeRelayUrl only checked the ws(s) prefix, so a URL
containing CR/LF or spaces passed through to the socket constructor — a
CRLF-injection shape. Now validated with the URL constructor and rejected for
any control character or whitespace, with a second guard at the socket
boundary regardless of caller.
- canonicalStringify recursed without cycle detection: a self-referential value
was a stack-overflow DoS rather than a wrong answer. Now guarded with a
WeakSet; a repeat serializes as a sentinel.
- The viewer's private mute payload was JSON.parsed with no size bound. Capped
at 128 KiB, checked before spending a decrypt on it.
What held up under this pass: address-coordinate parsing, the outbox per-author
relay cap (3), verifier handling of NaN/Infinity/string timestamps, and mute
tag robustness against non-string entries.
861 tests; audit clean; cold gates green.
Critical
- Any pubkey could publish a kind:30078 roles document and become root of a
deployment, granting itself every capability and denying anyone. Roles are now
accepted only from the configured root, compared against the constructor
value rather than current authority state so a bad ingest cannot move the
goalposts. Reproduced end to end before the fix; regression test added.
- Administrative state now fails closed without signature verification. Without
it, event.pubkey is an unauthenticated claim, so an authorship check alone
proves nothing — a relay can put the root's key on an event the root never
wrote. trustUnsignedEvents opts in for local and test use.
High
- Report categories could evade moderation entirely: resolveThresholds used
bracket truthiness, so "constructor" resolved to the Object constructor (all
gates undefined) and "__proto__" to Object.prototype, both bypassing the
default threshold. Own-property lookups and null-prototype tables throughout
core — policy, evaluator, evidence, authority, presets.
- Live subscriptions bypassed signature verification entirely; an attacker only
had to wait for the subscription rather than the load.
- The default administrative query asked relays for every kind:30078 and
kind:30000 event in existence, with no author, identifier, or limit. Now
filtered to governance identifiers with a bounded limit.
Medium
- Cached state was trusted as authority: a cache naming a different root is now
rejected, so same-origin tampering cannot persist a takeover.
- Provider attribute changes silently stopped working after the first one, and
concurrent starts leaked a transport. Generation-guarded.
- Unbounded growth in the report store, mute store, decision cache, and the
transport's per-subscription dedupe set — all now capped.
Also restores the on-wire `d` identifier to <namespace>:governance:<scope>:v1.
The BitGate rename replaced "governance:" too broadly and silently changed the
event format; both encoder and parser changed together, so round-trip tests
never noticed. The identifier names the kind of document, not the library.
Adds docs/security.md and 21 regression tests reproducing each finding.
832 tests.
Closes the four spec gaps from the audit, verified against the NIPs repo.
NIP-65 (kind 10002) — the one that was actually breaking things. Mute lists are
published by each contact to their own write relays, so querying a fixed relay
set finds only the subset who happen to write where this deployment reads. The
trust graph was silently under-fed and counts came out low, which looks like
"nobody muted them" rather than like a bug. Adds a relay-list codec, per-author
grouping with a fallback for accounts that have published no list, and
on-demand relay connections in the transport so an outbox query can reach
relays that were never configured.
NIP-51 private entries — the viewer's own private mutes are now decrypted
through their signer (NIP-44) and applied as viewer mutes. Someone who mutes
privately previously got no effect at all. The privacy boundary is asymmetric
and enforced at the codec: another account's private list is never decoded,
and the check lives there rather than in the caller's discipline.
NIP-02 (kind 3) — the runtime can build the trust graph itself, so
<bitgate-provider> no longer needs a host to supply a follow list. Set
trust="manual" to opt out.
NIP-56 vocabulary — SOCIAL_POLICY declared thresholds for "harassment", which
is not a NIP-56 type, so no standard client could ever trigger it. Replaced
with the defined vocabulary and asserted in tests. Commerce keeps its
marketplace-specific categories, now documented as deliberate, with defaults so
unclassified reports are never silently dropped.
Two bugs found by the new tests:
- checkout had no default threshold, so a flood of "other" reports did nothing
at the one place money changes hands. Now earns review, never auto-denial.
- describe() spread diagnostics after a derived field, so a stale zero shadowed
the live relay-list count.
Adds docs/nips.md recording coverage, deliberate choices, and remaining gaps —
NIP-32 labelling being the largest.
811 tests.
Renames the project and makes it as easy to adopt as BitLogin, which was the
stated bar and which it did not previously meet.
Rename (ADR 0005): @nostr-governance/* -> @bitgate/*, governance-* elements ->
bitgate-*, createGovernanceRuntime -> createBitGate. "Governance" reads as DAO
voting to this audience and named a category rather than a product; BitGate
names the benefit and joins the bit- family that BitVid and BitRoad compose
with. Domain types keep their names — the product is BitGate, the subject
matter is still governance. The migration plan is left as written; this ADR
records the change rather than rewriting history.
Drop-in distribution (ADR 0006): adopting BitGate meant supplying a transport,
authoring a policy, and wiring every element in JavaScript. Now:
- createRelayTransport(urls) — WebSocket transport with reconnection, backoff,
subscription replay, and cross-relay dedupe
- policy presets (social, commerce, admin-only) addressable by name; the engine
still carries no thresholds of its own
- <bitgate-provider> — configuration in markup, with descendants finding the
runtime through a bubbling context event rather than DOM walking, which
breaks across shadow boundaries
- npm run build:widget — a self-contained dist/bitgate.js
That last one came from running it in a browser rather than from a test. Bare
specifiers resolve in Node and vitest but not in a browser, so the widget
source worked everywhere except a plain static page — the one place it exists
to serve. The bundle is not committed: consumers build from reviewed source.
Also found by running it: the provider's ready event fires during element
upgrade, before a page that defines elements after parsing can attach a
listener, so it now exposes `ready` as a promise too.
Four bugs fixed along the way, three found by new tests and one by the browser:
admin state stayed silent when a roster change altered capabilities but no
denials; the relay transport sent every subscription twice on connect; the
commerce preset computed a meaningless visibility verdict at checkout and could
hide a listing from its own seller; <bitgate-status> threw on an unusable
decision.
759 tests; lint, typecheck, and both builds clean.
Stores (Phase 4): admin, trust graph, reports, trusted mutes, policy, and
overrides. Each owns one slice of state and emits only when effective state
changes, so a consumer can treat every emission as a real invalidation.
- report storage is keyed target -> reporter -> category, making deduplication
structural: relays redeliver constantly and a redelivery must not inflate a count
- contribution lists replace by (actor, kind) and ignore out-of-order older
documents, so a late delivery cannot resurrect removed entries
- mute lists prune against an injected clock
- viewer switching clears blocks, mutes, and overrides but keeps operator seeds,
which are configuration rather than viewer data
Runtime: event routing, chunked report subscriptions (200 per filter, not one
per target), viewer lifecycle, evaluation, diagnostics, idempotent teardown.
Transport, signer, and storage are injected; in-memory doubles ship for tests.
Commands (Phase 5): every mutation checks capability locally before the signer
is invoked, so an unauthorized actor cannot produce a signed event at all.
Protected actors are refused at the command boundary as well as during
reduction. Partial relay acceptance succeeds while keeping failures visible.
Also fixes the type build: every package's "types" field pointed at a path the
build never produced, so consumers got no types at all. Declarations now emit
per package and resolve.
103 runtime tests; 505 total.
Event formats live in codecs so the core never learns which kind stores roles,
which d identifier is used, or how a legacy list is shaped.
- bech32/NIP-19 decoding, verified against the NIP-19 test vector, so the core
can stay hex-only and dependency-free
- canonical v1 codec on kind:30078 with namespaced d identifiers, role rosters
carrying both role labels and explicit capabilities, and size-bounded policy
documents that return null rather than throwing on malformed JSON
- NIP-56 reports, resolving the report type across the three tag layouts
clients use and excluding relay hints from the type position
- NIP-51 mute lists; encrypted entries are flagged, never decoded
- legacy admin-list codec supporting all five migration stages, with editors
surfaced separately so a UI permission cannot silently become signing authority
- replaceable selection with a deterministic same-timestamp tiebreak, without
which two relays returning events in different orders would produce different
state on different clients
129 codec tests; 402 total.
Replace pnpm-only workspace: protocol with npm workspace deps, pin BitVid and
BitRoad reference commits, add Node types, and make the reference-import check
scan package graphs and source for forbidden bitvid/bitroad imports.