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.
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.
Follows bitlogin's layering: the engine stays headless and the widget is a
consumer of the published API, never a dependency of it. Building it was also a
test of that API — an element needing something unexposed would have been a
finding, the way the commerce example was.
Viewer elements: governance-veil (blur on restrict, disclosure on hide, warning
on warn), governance-report, governance-status. Moderator elements:
governance-capabilities, governance-action, governance-admin-panel.
Deliberate behaviors:
- hidden content leaves the accessibility tree rather than being visually
covered, so a screen reader does not read out what the viewer must not see
- a hidden decision renders a disclosure, not nothing: content that silently
vanishes is indistinguishable from a bug and leaves no route to appeal
- refused moderator actions stay visible and name the missing capability. A
hidden button teaches nothing about the authority model, and the command
layer enforces the same check independently — the UI is an explanation,
never the gate
- no element computes policy; wording maps from stable reason identifiers and
is overridable per application
Found while building it: GovernanceAdminStore emitted only when reduced denial
state changed, so revoking a role that denied nobody announced nothing, leaving
capability-gated UI and the decision cache stale.
The root tsconfig deliberately omits the DOM lib — that absence is what keeps
core, nostr, and runtime from reaching for browser globals — so the widget
carries its own DOM-aware config rather than relaxing the guarantee globally.
687 tests; lint, typecheck, and the declaration build pass from a cold checkout.
Several contracts existed and were tested in isolation while nothing actually
used them. Reported as complete previously; this corrects that.
- storage was injected and never referenced. Adds persist/hydrate, hydration
before querying relays, and last-known-good state with a stale flag when a
relay is unreachable, which is what makes the characterized cache-fallback
behavior real rather than reduction-level only
- signature verification existed as a tested helper the load path never called.
Now applied through an injected verifier before state becomes authoritative,
with rejection counts and an enabled/disabled readout in diagnostics
- trusted mute lists had no subscription flow, so the store never populated in
a real deployment. Subscribes by author, chunked
- community source references decoded but were never resolved. Curator lists
are now fetched and merged with a source marker, still capability-checked
- root-published policy was announced and discarded. Now applied, accepted only
from the root, and a malformed document leaves the working policy intact
- adds import/export and capability queries
Two design corrections found while wiring:
- the cache key hashed the entire roster, so it rotated whenever a moderator
was added and the cache was discarded. Now derived from root identity alone.
- the root administrator is a constructor option: it is deployment config, and
hydration could not otherwise find its own cache, since the key derives from
the very fingerprint the cache would have supplied.
634 tests; lint, typecheck, and the declaration build 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.