From 5e0fffb32e0ca7d9b09104254c4f3f2a44cde889 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:39:37 -0400 Subject: [PATCH] feat(nostr): implement Phase 3 codecs 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. --- packages/bitvid-compat/src/profile.js | 2 + packages/nostr/src/canonical.js | 344 ++++++++++++++++++++++++ packages/nostr/src/index.js | 77 +++++- packages/nostr/src/legacy.js | 207 ++++++++++++++ packages/nostr/src/mutes.js | 141 ++++++++++ packages/nostr/src/nip19.js | 203 ++++++++++++++ packages/nostr/src/replaceable.js | 145 ++++++++++ packages/nostr/src/reports.js | 189 +++++++++++++ packages/nostr/test/canonical.test.js | 297 ++++++++++++++++++++ packages/nostr/test/legacy.test.js | 186 +++++++++++++ packages/nostr/test/mutes.test.js | 124 +++++++++ packages/nostr/test/nip19.test.js | 111 ++++++++ packages/nostr/test/replaceable.test.js | 126 +++++++++ packages/nostr/test/reports.test.js | 158 +++++++++++ 14 files changed, 2308 insertions(+), 2 deletions(-) create mode 100644 packages/nostr/src/canonical.js create mode 100644 packages/nostr/src/legacy.js create mode 100644 packages/nostr/src/mutes.js create mode 100644 packages/nostr/src/nip19.js create mode 100644 packages/nostr/src/replaceable.js create mode 100644 packages/nostr/src/reports.js create mode 100644 packages/nostr/test/canonical.test.js create mode 100644 packages/nostr/test/legacy.test.js create mode 100644 packages/nostr/test/mutes.test.js create mode 100644 packages/nostr/test/nip19.test.js create mode 100644 packages/nostr/test/replaceable.test.js create mode 100644 packages/nostr/test/reports.test.js diff --git a/packages/bitvid-compat/src/profile.js b/packages/bitvid-compat/src/profile.js index a1dbbf5..cc7f552 100644 --- a/packages/bitvid-compat/src/profile.js +++ b/packages/bitvid-compat/src/profile.js @@ -69,6 +69,8 @@ const MUTE_THRESHOLDS = { * Surfaces differ only in how they treat a hide and whether a viewer may * override: discovery feeds decline to hard-hide so that a single stale list * cannot empty a feed, while playback enforces the full decision. + * + * @type {import('@nostr-governance/core').PolicyDefinition} */ export const REFERENCE_POLICY = createPolicyDefinition({ id: "reference-video-app", diff --git a/packages/nostr/src/canonical.js b/packages/nostr/src/canonical.js new file mode 100644 index 0000000..49fbb00 --- /dev/null +++ b/packages/nostr/src/canonical.js @@ -0,0 +1,344 @@ +// Canonical v1 governance codec. +// +// Governance documents are parameterized-replaceable kind:30078 events with a +// namespaced `d` identifier. The core never learns these details — it receives +// decoded contributions, roles, and policy. + +import { normalizeAddress } from "@nostr-governance/core"; + +import { normalizeEventIdInput, normalizePubkeyInput } from "./nip19.js"; +import { getTagValue, getTags } from "./replaceable.js"; + +/** + * @typedef {import('./replaceable.js').NostrEvent} NostrEvent + * @typedef {import('@nostr-governance/core').Contribution} Contribution + * @typedef {import('@nostr-governance/core').GovernanceCapability} GovernanceCapability + */ + +/** Parameterized-replaceable application-data kind used for governance documents. */ +export const CANONICAL_KIND = 30078; + +/** Schema version carried in the `v` tag. */ +export const CANONICAL_VERSION = "1"; + +/** @type {Record} */ +const SCOPE_TO_KIND = { + "user-allow": "user-allow", + "user-deny": "user-deny", + "event-deny": "event-deny", + "address-deny": "address-deny", + "trust-seed": "trust-seed", +}; + +/** + * Build a canonical `d` identifier. + * @param {string} namespace - Application namespace, e.g. "bitroad" + * @param {string} scope - e.g. "user-deny" + * @returns {string} + */ +export function canonicalIdentifier(namespace, scope) { + return `${namespace}:governance:${scope}:v${CANONICAL_VERSION}`; +} + +/** + * Parse a canonical `d` identifier. + * @param {string} identifier + * @returns {{ namespace: string, scope: string, version: string } | null} + */ +export function parseIdentifier(identifier) { + if (typeof identifier !== "string") { + return null; + } + const match = /^([^:]+):governance:([^:]+):v(\d+)$/.exec(identifier.trim()); + if (!match) { + return null; + } + return { namespace: match[1], scope: match[2], version: match[3] }; +} + +/** + * Whether an event is a canonical governance document. + * @param {NostrEvent} event + * @param {string} [namespace] - Restrict to one application namespace + * @returns {boolean} + */ +export function isCanonicalGovernanceEvent(event, namespace) { + if (!event || event.kind !== CANONICAL_KIND) { + return false; + } + const parsed = parseIdentifier(getTagValue(event, "d")); + if (!parsed) { + return false; + } + return namespace ? parsed.namespace === namespace : true; +} + +/** + * Decode a contribution list event. + * + * Malformed tags are skipped rather than failing the whole document: a single + * bad entry in a moderator's list must not silently drop every other entry. + * + * @param {NostrEvent} event + * @returns {Contribution|null} + */ +export function decodeContribution(event) { + if (!isCanonicalGovernanceEvent(event)) { + return null; + } + + const parsed = parseIdentifier(getTagValue(event, "d")); + const scope = getTagValue(event, "scope") || parsed?.scope || ""; + const kind = SCOPE_TO_KIND[scope]; + if (!kind) { + return null; + } + + const actor = normalizePubkeyInput(event.pubkey); + if (!actor) { + return null; + } + + /** @type {import('@nostr-governance/core').GovernanceTarget[]} */ + const targets = []; + + if (kind === "event-deny") { + for (const tag of getTags(event, "e")) { + const id = normalizeEventIdInput(tag[1]); + if (id) { + targets.push({ type: "event", id }); + } + } + } else if (kind === "address-deny") { + for (const tag of getTags(event, "a")) { + const target = decodeAddressCoordinate(tag[1]); + if (target) { + targets.push(target); + } + } + } else { + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (pubkey) { + targets.push({ type: "user", pubkey }); + } + } + } + + /** @type {Contribution} */ + const contribution = { actor, kind, targets, createdAt: event.created_at }; + + const source = getTagValue(event, "source"); + if (source) { + contribution.source = source; + } + + return contribution; +} + +/** + * Decode an `a` coordinate (`kind:pubkey:d`) into an address target. + * @param {string} coordinate + * @returns {import('@nostr-governance/core').AddressTarget|null} + */ +export function decodeAddressCoordinate(coordinate) { + if (typeof coordinate !== "string") { + return null; + } + + // The d-tag may itself contain colons, so split only on the first two. + const first = coordinate.indexOf(":"); + const second = coordinate.indexOf(":", first + 1); + if (first < 1 || second < 0) { + return null; + } + + const kind = coordinate.slice(0, first); + const pubkey = coordinate.slice(first + 1, second); + const identifier = coordinate.slice(second + 1); + + if (!/^\d+$/.test(kind) || !identifier) { + return null; + } + + return normalizeAddress(kind, normalizePubkeyInput(pubkey), identifier); +} + +/** + * Encode an address target back into an `a` coordinate. + * @param {import('@nostr-governance/core').AddressTarget} target + * @returns {string} + */ +export function encodeAddressCoordinate(target) { + return `${target.kind}:${target.pubkey}:${target.identifier}`; +} + +/** + * Decode a role document into authority-state input. + * + * `p` tags carry role labels and `cap` tags carry explicit capabilities, so an + * application can grant a capability without inventing a role for it. + * + * @param {NostrEvent} event + * @returns {{ root: string, actors: Record, capabilities: Record, protectedActors: string[] } | null} + */ +export function decodeRoles(event) { + if (!isCanonicalGovernanceEvent(event)) { + return null; + } + const parsed = parseIdentifier(getTagValue(event, "d")); + if (parsed?.scope !== "roles") { + return null; + } + + const root = normalizePubkeyInput(event.pubkey); + if (!root) { + return null; + } + + /** @type {Record} */ + const actors = {}; + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + const role = typeof tag[2] === "string" ? tag[2].trim() : ""; + if (!pubkey || !role) { + continue; + } + actors[pubkey] = actors[pubkey] ?? []; + if (!actors[pubkey].includes(role)) { + actors[pubkey].push(role); + } + } + + /** @type {Record} */ + const capabilities = {}; + for (const tag of getTags(event, "cap")) { + const pubkey = normalizePubkeyInput(tag[1]); + const capability = typeof tag[2] === "string" ? tag[2].trim() : ""; + if (!pubkey || !capability) { + continue; + } + capabilities[pubkey] = capabilities[pubkey] ?? []; + if (!capabilities[pubkey].includes(capability)) { + capabilities[pubkey].push(capability); + } + } + + const protectedActors = [root]; + for (const tag of getTags(event, "protected")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (pubkey && !protectedActors.includes(pubkey)) { + protectedActors.push(pubkey); + } + } + + return { root, actors, capabilities, protectedActors }; +} + +/** Largest policy document accepted, in bytes of JSON content. */ +export const MAX_POLICY_BYTES = 64 * 1024; + +/** + * Decode a policy document. + * + * Content is size-bounded before parsing so that a hostile relay cannot force + * an unbounded JSON parse, and parse failures return null rather than throwing: + * a malformed policy must leave the previous policy in place. + * + * @param {NostrEvent} event + * @returns {Object|null} + */ +export function decodePolicy(event) { + if (!isCanonicalGovernanceEvent(event)) { + return null; + } + const parsed = parseIdentifier(getTagValue(event, "d")); + if (parsed?.scope !== "policy") { + return null; + } + + const content = typeof event.content === "string" ? event.content : ""; + if (!content || content.length > MAX_POLICY_BYTES) { + return null; + } + + try { + const policy = JSON.parse(content); + if (!policy || typeof policy !== "object" || Array.isArray(policy)) { + return null; + } + return policy; + } catch { + return null; + } +} + +/** + * Encode a contribution as a canonical event template. + * + * Returns an unsigned template: signing belongs to the application's signer. + * + * @param {Contribution} contribution + * @param {string} namespace + * @returns {{ kind: number, content: string, tags: string[][] }} + */ +export function encodeContribution(contribution, namespace) { + const tags = [ + ["d", canonicalIdentifier(namespace, contribution.kind)], + ["v", CANONICAL_VERSION], + ["client", "nostr-governance"], + ["scope", contribution.kind], + ]; + + if (contribution.source) { + tags.push(["source", contribution.source]); + } + + for (const target of contribution.targets ?? []) { + if (target.type === "user") { + tags.push(["p", target.pubkey]); + } else if (target.type === "event") { + tags.push(["e", target.id]); + } else if (target.type === "address") { + tags.push(["a", encodeAddressCoordinate(target)]); + } + } + + return { kind: CANONICAL_KIND, content: "", tags }; +} + +/** + * Encode a role roster as a canonical event template. + * @param {Object} roster + * @param {Record} [roster.actors] + * @param {Record} [roster.capabilities] + * @param {string[]} [roster.protectedActors] + * @param {string} namespace + * @returns {{ kind: number, content: string, tags: string[][] }} + */ +export function encodeRoles(roster, namespace) { + const tags = [ + ["d", canonicalIdentifier(namespace, "roles")], + ["v", CANONICAL_VERSION], + ["client", "nostr-governance"], + ["scope", "roles"], + ]; + + for (const [pubkey, roles] of Object.entries(roster.actors ?? {})) { + for (const role of roles) { + tags.push(["p", pubkey, role]); + } + } + + for (const [pubkey, capabilities] of Object.entries(roster.capabilities ?? {})) { + for (const capability of capabilities) { + tags.push(["cap", pubkey, capability]); + } + } + + for (const pubkey of roster.protectedActors ?? []) { + tags.push(["protected", pubkey]); + } + + return { kind: CANONICAL_KIND, content: "", tags }; +} diff --git a/packages/nostr/src/index.js b/packages/nostr/src/index.js index 13f6f78..cc609cc 100644 --- a/packages/nostr/src/index.js +++ b/packages/nostr/src/index.js @@ -1,2 +1,75 @@ -// Placeholder for nostr module exports -export {}; \ No newline at end of file +// @nostr-governance/nostr +// +// Codecs translating Nostr events into the core's vocabulary. Event formats +// live here so the core never learns which kind stores roles, which `d` +// identifier is used, or how a legacy list is shaped. + +export { + bech32Decode, + convertBits, + decodeNpub, + decodeNote, + normalizePubkeyInput, + normalizeEventIdInput, +} from "./nip19.js"; + +export { + getTagValue, + getTags, + coordinateOf, + selectReplaceable, + selectLatest, + verifyEvents, +} from "./replaceable.js"; + +export { + CANONICAL_KIND, + CANONICAL_VERSION, + MAX_POLICY_BYTES, + canonicalIdentifier, + parseIdentifier, + isCanonicalGovernanceEvent, + decodeContribution, + decodeAddressCoordinate, + encodeAddressCoordinate, + decodeRoles, + decodePolicy, + encodeContribution, + encodeRoles, +} from "./canonical.js"; + +export { + REPORT_KIND, + isRelayHint, + normalizeCategory, + extractReportType, + decodeReport, + encodeReport, +} from "./reports.js"; + +export { + MUTE_LIST_KIND, + extractMuteCategory, + decodeMuteList, + toMuteRecords, + encodeMuteList, +} from "./mutes.js"; + +export { + LEGACY_KIND, + LEGACY_IDENTIFIERS, + LEGACY_EDITORS_IDENTIFIER, + LEGACY_COMMUNITY_SOURCES_IDENTIFIER, + isLegacyAdminEvent, + decodeLegacyList, + decodeLegacyEditors, + decodeLegacyCommunitySources, + decodeMixedContributions, +} from "./legacy.js"; + +/** + * @typedef {import('./replaceable.js').NostrEvent} NostrEvent + * @typedef {import('./replaceable.js').SignatureVerifier} SignatureVerifier + * @typedef {import('./reports.js').DecodedReport} DecodedReport + * @typedef {import('./mutes.js').DecodedMuteList} DecodedMuteList + */ diff --git a/packages/nostr/src/legacy.js b/packages/nostr/src/legacy.js new file mode 100644 index 0000000..a6bd937 --- /dev/null +++ b/packages/nostr/src/legacy.js @@ -0,0 +1,207 @@ +// Legacy administrative-list codec. +// +// The reference application stores administrative state in kind:30000 lists +// with unversioned `d` identifiers. Reading them is not optional: a destructive +// migration would drop existing moderation state, so the runtime reads legacy +// and canonical formats side by side and the application chooses what to write. +// +// Migration stages this codec supports: +// 1. read legacy, write legacy +// 2. read both, write legacy +// 3. read both, dual-write +// 4. read both, write canonical +// 5. retain legacy reading indefinitely + +import { normalizeEventIdInput, normalizePubkeyInput } from "./nip19.js"; +import { getTagValue, getTags } from "./replaceable.js"; + +/** + * @typedef {import('./replaceable.js').NostrEvent} NostrEvent + * @typedef {import('@nostr-governance/core').Contribution} Contribution + */ + +/** Legacy people-list kind used by the reference application. */ +export const LEGACY_KIND = 30000; + +/** + * Legacy `d` identifiers mapped onto contribution kinds. + * + * `editors` has no contribution equivalent — it granted UI permissions rather + * than signing authority — so it is surfaced separately by {@link decodeLegacyEditors}. + * + * @type {Record} + */ +export const LEGACY_IDENTIFIERS = { + "bitvid:admin:whitelist": "user-allow", + "bitvid:admin:blacklist": "user-deny", + "bitvid:admin:event-blacklist": "event-deny", +}; + +/** Legacy identifier naming the editor roster. */ +export const LEGACY_EDITORS_IDENTIFIER = "bitvid:admin:editors"; + +/** Legacy identifier naming community blacklist sources. */ +export const LEGACY_COMMUNITY_SOURCES_IDENTIFIER = "bitvid:admin:community-sources"; + +/** + * Whether an event is a recognized legacy administrative list. + * @param {NostrEvent} event + * @returns {boolean} + */ +export function isLegacyAdminEvent(event) { + if (!event || event.kind !== LEGACY_KIND) { + return false; + } + const identifier = getTagValue(event, "d"); + return ( + identifier in LEGACY_IDENTIFIERS || + identifier === LEGACY_EDITORS_IDENTIFIER || + identifier === LEGACY_COMMUNITY_SOURCES_IDENTIFIER + ); +} + +/** + * Decode a legacy administrative list into a contribution. + * + * Legacy lists accept both hex and npub entries, so every value is normalized + * before it becomes a target. + * + * @param {NostrEvent} event + * @returns {Contribution|null} + */ +export function decodeLegacyList(event) { + if (!event || event.kind !== LEGACY_KIND) { + return null; + } + + const identifier = getTagValue(event, "d"); + const kind = LEGACY_IDENTIFIERS[identifier]; + if (!kind) { + return null; + } + + const actor = normalizePubkeyInput(event.pubkey); + if (!actor) { + return null; + } + + /** @type {import('@nostr-governance/core').GovernanceTarget[]} */ + const targets = []; + + if (kind === "event-deny") { + for (const tag of getTags(event, "e")) { + const id = normalizeEventIdInput(tag[1]); + if (id) { + targets.push({ type: "event", id }); + } + } + } else { + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (pubkey) { + targets.push({ type: "user", pubkey }); + } + } + } + + return { actor, kind, targets, createdAt: event.created_at }; +} + +/** + * Decode the legacy editor roster. + * + * Editors were a UI-level permission, not cryptographic authority. They are + * returned as plain pubkeys so an application can migrate them into real roles + * deliberately, rather than having them silently become moderators. + * + * @param {NostrEvent} event + * @returns {string[]} + */ +export function decodeLegacyEditors(event) { + if (!event || event.kind !== LEGACY_KIND) { + return []; + } + if (getTagValue(event, "d") !== LEGACY_EDITORS_IDENTIFIER) { + return []; + } + + /** @type {string[]} */ + const editors = []; + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (pubkey && !editors.includes(pubkey)) { + editors.push(pubkey); + } + } + return editors; +} + +/** + * Decode community blacklist source references. + * + * The root administrator publishes pointers to curator lists; the runtime + * fetches those lists separately. Each reference is a coordinate the caller can + * resolve. + * + * @param {NostrEvent} event + * @returns {Array<{ curator: string, identifier: string, kind: number }>} + */ +export function decodeLegacyCommunitySources(event) { + if (!event || event.kind !== LEGACY_KIND) { + return []; + } + if (getTagValue(event, "d") !== LEGACY_COMMUNITY_SOURCES_IDENTIFIER) { + return []; + } + + /** @type {Array<{ curator: string, identifier: string, kind: number }>} */ + const sources = []; + + for (const tag of getTags(event, "a")) { + const coordinate = typeof tag[1] === "string" ? tag[1] : ""; + const first = coordinate.indexOf(":"); + const second = coordinate.indexOf(":", first + 1); + if (first < 1 || second < 0) { + continue; + } + + const kind = Number.parseInt(coordinate.slice(0, first), 10); + const curator = normalizePubkeyInput(coordinate.slice(first + 1, second)); + const identifier = coordinate.slice(second + 1); + + if (!Number.isFinite(kind) || !curator || !identifier) { + continue; + } + sources.push({ curator, identifier, kind }); + } + + return sources; +} + +/** + * Decode any recognized legacy or canonical administrative event. + * + * Convenience for the migration stages where both formats are read at once. + * + * @param {NostrEvent[]} events + * @param {(event: NostrEvent) => Contribution|null} canonicalDecoder + * @returns {Contribution[]} + */ +export function decodeMixedContributions(events, canonicalDecoder) { + /** @type {Contribution[]} */ + const contributions = []; + + for (const event of events ?? []) { + const canonical = canonicalDecoder(event); + if (canonical) { + contributions.push(canonical); + continue; + } + const legacy = decodeLegacyList(event); + if (legacy) { + contributions.push(legacy); + } + } + + return contributions; +} diff --git a/packages/nostr/src/mutes.js b/packages/nostr/src/mutes.js new file mode 100644 index 0000000..988f152 --- /dev/null +++ b/packages/nostr/src/mutes.js @@ -0,0 +1,141 @@ +// NIP-51 mute list codec. +// +// A mute list is a replaceable kind:10000 event whose public entries are `p` +// tags. Encrypted entries live in `content` and are deliberately not decoded +// here: a private mute is private, and the governance engine has no business +// reading another account's encrypted list even when it could. + +import { normalizePubkeyInput } from "./nip19.js"; +import { getTags } from "./replaceable.js"; + +/** + * @typedef {import('./replaceable.js').NostrEvent} NostrEvent + */ + +/** NIP-51 mute list kind. */ +export const MUTE_LIST_KIND = 10000; + +/** + * Resolve the category attached to a mute tag. + * + * Clients disagree about the position: some put it at index 3, some at index 2 + * where a relay hint would otherwise sit. Index 2 is only accepted when it is + * clearly not a URL. + * + * @param {string[]} tag + * @returns {string} + */ +export function extractMuteCategory(tag) { + if (!Array.isArray(tag)) { + return ""; + } + + const direct = typeof tag[3] === "string" ? tag[3].trim().toLowerCase() : ""; + if (direct) { + return direct; + } + + const candidate = typeof tag[2] === "string" ? tag[2].trim() : ""; + if (!candidate) { + return ""; + } + + const lower = candidate.toLowerCase(); + const looksLikeRelay = + lower.startsWith("wss://") || + lower.startsWith("ws://") || + lower.startsWith("https://") || + lower.startsWith("http://"); + + return looksLikeRelay ? "" : lower; +} + +/** + * @typedef {Object} DecodedMuteList + * @property {string} owner - Pubkey that published the list + * @property {number} updatedAt - Unix seconds, used for window expiry + * @property {Array<{ pubkey: string, category?: string }>} entries + * @property {boolean} hasEncryptedEntries - Whether private entries were present but not read + */ + +/** + * Decode a NIP-51 mute list. + * @param {NostrEvent} event + * @returns {DecodedMuteList|null} + */ +export function decodeMuteList(event) { + if (!event || event.kind !== MUTE_LIST_KIND) { + return null; + } + + const owner = normalizePubkeyInput(event.pubkey); + if (!owner) { + return null; + } + + /** @type {Array<{ pubkey: string, category?: string }>} */ + const entries = []; + /** @type {Set} */ + const seen = new Set(); + + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (!pubkey || seen.has(pubkey)) { + continue; + } + seen.add(pubkey); + + const category = extractMuteCategory(tag); + entries.push(category ? { pubkey, category } : { pubkey }); + } + + return { + owner, + updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0, + entries, + hasEncryptedEntries: typeof event.content === "string" && event.content.length > 0, + }; +} + +/** + * Turn decoded mute lists into per-target mute records for the evaluator. + * + * Keyed by governance target key so the result drops straight into a snapshot. + * + * @param {DecodedMuteList[]} lists + * @returns {Map>} + */ +export function toMuteRecords(lists) { + /** @type {Map>} */ + const records = new Map(); + + for (const list of lists ?? []) { + if (!list) { + continue; + } + for (const entry of list.entries) { + const key = `user:${entry.pubkey}`; + const bucket = records.get(key) ?? []; + bucket.push({ + muter: list.owner, + ...(entry.category ? { category: entry.category } : {}), + updatedAt: list.updatedAt, + }); + records.set(key, bucket); + } + } + + return records; +} + +/** + * Encode a mute list as an unsigned event template. + * @param {Array<{ pubkey: string, category?: string }>} entries + * @returns {{ kind: number, content: string, tags: string[][] }} + */ +export function encodeMuteList(entries) { + const tags = (entries ?? []).map((entry) => + entry.category ? ["p", entry.pubkey, "", entry.category] : ["p", entry.pubkey], + ); + return { kind: MUTE_LIST_KIND, content: "", tags }; +} diff --git a/packages/nostr/src/nip19.js b/packages/nostr/src/nip19.js new file mode 100644 index 0000000..be57c8e --- /dev/null +++ b/packages/nostr/src/nip19.js @@ -0,0 +1,203 @@ +// bech32 decoding for Nostr identifiers. +// +// Implemented here rather than pulled from a dependency so that @nostr-governance/core +// can stay dependency-free and hex-only: decoding happens at the edge, in the +// codecs, and the core only ever sees normalized hex. + +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +/** @type {Map} */ +const CHARSET_MAP = new Map(); +for (let index = 0; index < CHARSET.length; index += 1) { + CHARSET_MAP.set(CHARSET[index], index); +} + +const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + +/** + * @param {number[]} values + * @returns {number} + */ +function polymod(values) { + let checksum = 1; + for (const value of values) { + const top = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ value; + for (let bit = 0; bit < GENERATORS.length; bit += 1) { + if ((top >>> bit) & 1) { + checksum ^= GENERATORS[bit]; + } + } + } + return checksum; +} + +/** + * @param {string} hrp + * @returns {number[]} + */ +function hrpExpand(hrp) { + /** @type {number[]} */ + const expanded = []; + for (let index = 0; index < hrp.length; index += 1) { + expanded.push(hrp.charCodeAt(index) >>> 5); + } + expanded.push(0); + for (let index = 0; index < hrp.length; index += 1) { + expanded.push(hrp.charCodeAt(index) & 31); + } + return expanded; +} + +/** + * Decode a bech32 string into its human-readable part and data words. + * @param {string} value + * @returns {{ hrp: string, words: number[] } | null} + */ +export function bech32Decode(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + // Mixed case is invalid per BIP-173. + const lower = trimmed.toLowerCase(); + if (trimmed !== lower && trimmed !== trimmed.toUpperCase()) { + return null; + } + + const separator = lower.lastIndexOf("1"); + if (separator < 1 || separator + 7 > lower.length) { + return null; + } + + const hrp = lower.slice(0, separator); + /** @type {number[]} */ + const words = []; + for (let index = separator + 1; index < lower.length; index += 1) { + const word = CHARSET_MAP.get(lower[index]); + if (word === undefined) { + return null; + } + words.push(word); + } + + if (polymod(hrpExpand(hrp).concat(words)) !== 1) { + return null; + } + + return { hrp, words: words.slice(0, -6) }; +} + +/** + * Regroup bit-packed data, used to turn 5-bit bech32 words into bytes. + * @param {number[]} data + * @param {number} fromBits + * @param {number} toBits + * @param {boolean} [pad] + * @returns {number[] | null} + */ +export function convertBits(data, fromBits, toBits, pad = true) { + /** @type {number[]} */ + const result = []; + let accumulator = 0; + let bits = 0; + const maxValue = (1 << toBits) - 1; + const maxAccumulator = (1 << (fromBits + toBits - 1)) - 1; + + for (const value of data) { + if (value < 0 || value >> fromBits) { + return null; + } + accumulator = ((accumulator << fromBits) | value) & maxAccumulator; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((accumulator >>> bits) & maxValue); + } + } + + if (pad) { + if (bits) { + result.push((accumulator << (toBits - bits)) & maxValue); + } + } else if (bits >= fromBits || ((accumulator << (toBits - bits)) & maxValue)) { + return null; + } + + return result; +} + +/** + * Decode an `npub` into a 64-character hex pubkey. + * @param {string} npub + * @returns {string} Empty string when the input is not a valid npub + */ +export function decodeNpub(npub) { + const decoded = bech32Decode(npub); + if (!decoded || decoded.hrp !== "npub") { + return ""; + } + + const bytes = convertBits(decoded.words, 5, 8, false); + if (!bytes || bytes.length !== 32) { + return ""; + } + + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Decode a `note` into a 64-character hex event id. + * @param {string} note + * @returns {string} Empty string when the input is not a valid note + */ +export function decodeNote(note) { + const decoded = bech32Decode(note); + if (!decoded || decoded.hrp !== "note") { + return ""; + } + + const bytes = convertBits(decoded.words, 5, 8, false); + if (!bytes || bytes.length !== 32) { + return ""; + } + + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Normalize a pubkey given as hex or npub. + * @param {string} value + * @returns {string} Lowercase hex, or empty string when invalid + */ +export function normalizePubkeyInput(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + return decodeNpub(trimmed); +} + +/** + * Normalize an event id given as hex or note. + * @param {string} value + * @returns {string} Lowercase hex, or empty string when invalid + */ +export function normalizeEventIdInput(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + return decodeNote(trimmed); +} diff --git a/packages/nostr/src/replaceable.js b/packages/nostr/src/replaceable.js new file mode 100644 index 0000000..ae2f748 --- /dev/null +++ b/packages/nostr/src/replaceable.js @@ -0,0 +1,145 @@ +// Replaceable-event selection. +// +// Parameterized-replaceable events (kind 30000-39999) are identified by +// (kind, pubkey, d-tag). Only the newest event per coordinate is effective. + +/** + * @typedef {Object} NostrEvent + * @property {string} id + * @property {string} pubkey + * @property {number} kind + * @property {number} created_at + * @property {string[][]} tags + * @property {string} content + * @property {string} [sig] + */ + +/** + * Read the first value of a tag. + * @param {NostrEvent} event + * @param {string} name + * @returns {string} + */ +export function getTagValue(event, name) { + for (const tag of event.tags ?? []) { + if (Array.isArray(tag) && tag[0] === name && typeof tag[1] === "string") { + return tag[1]; + } + } + return ""; +} + +/** + * Every value of a repeated tag. + * @param {NostrEvent} event + * @param {string} name + * @returns {string[][]} + */ +export function getTags(event, name) { + return (event.tags ?? []).filter((tag) => Array.isArray(tag) && tag[0] === name); +} + +/** + * The replaceable coordinate for an event: `kind:pubkey:d`. + * @param {NostrEvent} event + * @returns {string} + */ +export function coordinateOf(event) { + return `${event.kind}:${event.pubkey}:${getTagValue(event, "d")}`; +} + +/** + * Select the effective event for each replaceable coordinate. + * + * Newest `created_at` wins. Ties are broken by the lexicographically smallest + * event id, which is arbitrary but deterministic — without a tiebreak, two + * relays returning the same two events in different orders would produce + * different state on different clients. + * + * @param {NostrEvent[]} events + * @returns {Map} Coordinate to winning event + */ +export function selectReplaceable(events) { + /** @type {Map} */ + const winners = new Map(); + + for (const event of events ?? []) { + if (!event || typeof event.kind !== "number") { + continue; + } + const coordinate = coordinateOf(event); + const current = winners.get(coordinate); + + if (!current) { + winners.set(coordinate, event); + continue; + } + + if (event.created_at > current.created_at) { + winners.set(coordinate, event); + continue; + } + + if (event.created_at === current.created_at && event.id < current.id) { + winners.set(coordinate, event); + } + } + + return winners; +} + +/** + * The single newest event from a list, using the same tiebreak. + * @param {NostrEvent[]} events + * @returns {NostrEvent|null} + */ +export function selectLatest(events) { + let winner = null; + for (const event of events ?? []) { + if (!event) { + continue; + } + if ( + !winner || + event.created_at > winner.created_at || + (event.created_at === winner.created_at && event.id < winner.id) + ) { + winner = event; + } + } + return winner; +} + +/** + * @typedef {(event: NostrEvent) => boolean | Promise} SignatureVerifier + */ + +/** + * Filter events through an injected signature verifier. + * + * Verification is injected rather than implemented here: signature checking + * needs a crypto library, and forcing one into this package would make it + * unusable in environments that already have their own. + * + * @param {NostrEvent[]} events + * @param {SignatureVerifier} [verify] + * @returns {Promise} + */ +export async function verifyEvents(events, verify) { + if (typeof verify !== "function") { + return [...(events ?? [])]; + } + + /** @type {NostrEvent[]} */ + const verified = []; + for (const event of events ?? []) { + try { + if (await verify(event)) { + verified.push(event); + } + } catch { + // A verifier that throws is treated as a rejection, never as a pass. + } + } + return verified; +} diff --git a/packages/nostr/src/reports.js b/packages/nostr/src/reports.js new file mode 100644 index 0000000..dd88734 --- /dev/null +++ b/packages/nostr/src/reports.js @@ -0,0 +1,189 @@ +// NIP-56 report codec. +// +// Reports name a target through `e` (event) or `p` (user) tags and carry a +// report type. The type may appear in several places depending on the client +// that published it, so resolution tries each in a fixed order. + +import { normalizeEventIdInput, normalizePubkeyInput } from "./nip19.js"; +import { getTags } from "./replaceable.js"; + +/** + * @typedef {import('./replaceable.js').NostrEvent} NostrEvent + * @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget + */ + +/** NIP-56 report kind. */ +export const REPORT_KIND = 1984; + +/** + * Whether a value looks like a relay hint rather than a report type. + * @param {unknown} value + * @returns {boolean} + */ +export function isRelayHint(value) { + if (typeof value !== "string") { + return false; + } + const lower = value.trim().toLowerCase(); + return ( + lower.startsWith("wss://") || + lower.startsWith("ws://") || + lower.startsWith("https://") || + lower.startsWith("http://") + ); +} + +/** + * Normalize a report category. + * @param {unknown} value + * @returns {string} + */ +export function normalizeCategory(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim().toLowerCase(); +} + +/** + * Resolve the report type for an event. + * + * Order: an explicit `report`/`type` tag, then the third element of the + * matching target tag, then a `t` hashtag. Relay hints occupy the same tag + * position as the type in some clients, so they are excluded explicitly. + * + * @param {NostrEvent} event + * @param {string} [targetValue] - The e/p value the type should describe + * @returns {string} + */ +export function extractReportType(event, targetValue = "") { + for (const tag of event.tags ?? []) { + if (!Array.isArray(tag)) { + continue; + } + if (tag[0] === "report" || tag[0] === "type") { + const category = normalizeCategory(tag[1]); + if (category) { + return category; + } + } + } + + if (targetValue) { + for (const tag of event.tags ?? []) { + if (!Array.isArray(tag) || (tag[0] !== "e" && tag[0] !== "p")) { + continue; + } + if (tag[1] !== targetValue || isRelayHint(tag[2])) { + continue; + } + const category = normalizeCategory(tag[2]); + if (category) { + return category; + } + } + } + + for (const tag of getTags(event, "t")) { + const category = normalizeCategory(tag[1]); + if (category) { + return category; + } + } + + return ""; +} + +/** + * @typedef {Object} DecodedReport + * @property {string} reporter + * @property {GovernanceTarget} target + * @property {string} category + * @property {number} createdAt + */ + +/** + * Decode a NIP-56 report into zero or more report records. + * + * An event tag wins over a p tag when both are present: reporting a specific + * note is more precise than reporting its author, and counting both would + * double-count one reporter's single action. + * + * @param {NostrEvent} event + * @returns {DecodedReport[]} + */ +export function decodeReport(event) { + if (!event || event.kind !== REPORT_KIND) { + return []; + } + + const reporter = normalizePubkeyInput(event.pubkey); + if (!reporter) { + return []; + } + + const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; + + /** @type {DecodedReport[]} */ + const reports = []; + + for (const tag of getTags(event, "e")) { + const id = normalizeEventIdInput(tag[1]); + if (!id) { + continue; + } + const category = extractReportType(event, tag[1]); + if (!category) { + continue; + } + reports.push({ reporter, target: { type: "event", id }, category, createdAt }); + } + + if (reports.length > 0) { + return reports; + } + + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (!pubkey) { + continue; + } + const category = extractReportType(event, tag[1]); + if (!category) { + continue; + } + reports.push({ reporter, target: { type: "user", pubkey }, category, createdAt }); + } + + return reports; +} + +/** + * Encode a report as an unsigned NIP-56 event template. + * @param {GovernanceTarget} target + * @param {string} category + * @param {string} [content] + * @returns {{ kind: number, content: string, tags: string[][] }} + */ +export function encodeReport(target, category, content = "") { + const normalized = normalizeCategory(category); + if (!normalized) { + throw new Error("Report category must be a non-empty string"); + } + + /** @type {string[][]} */ + const tags = [["report", normalized]]; + + if (target.type === "event") { + tags.push(["e", target.id, normalized]); + if (target.author) { + tags.push(["p", target.author]); + } + } else if (target.type === "user") { + tags.push(["p", target.pubkey, normalized]); + } else { + tags.push(["a", `${target.kind}:${target.pubkey}:${target.identifier}`, normalized]); + } + + return { kind: REPORT_KIND, content, tags }; +} diff --git a/packages/nostr/test/canonical.test.js b/packages/nostr/test/canonical.test.js new file mode 100644 index 0000000..c973c77 --- /dev/null +++ b/packages/nostr/test/canonical.test.js @@ -0,0 +1,297 @@ +import { describe, expect, it } from "vitest"; + +import { + CANONICAL_KIND, + MAX_POLICY_BYTES, + canonicalIdentifier, + decodeAddressCoordinate, + decodeContribution, + decodePolicy, + decodeRoles, + encodeAddressCoordinate, + encodeContribution, + encodeRoles, + isCanonicalGovernanceEvent, + parseIdentifier, +} from "../src/canonical.js"; + +const ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CREATOR = "d4".repeat(32); +const EVENT_ID = "1b".repeat(32); + +/** + * @param {Partial} parts + * @returns {import('../src/replaceable.js').NostrEvent} + */ +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: MODERATOR, + kind: CANONICAL_KIND, + created_at: 1000, + tags: [], + content: "", + ...parts, +}); + +describe("canonical identifiers", () => { + it("builds a namespaced identifier", () => { + expect(canonicalIdentifier("bitroad", "address-deny")).toBe( + "bitroad:governance:address-deny:v1", + ); + }); + + it("round-trips through the parser", () => { + expect(parseIdentifier(canonicalIdentifier("app", "user-deny"))).toEqual({ + namespace: "app", + scope: "user-deny", + version: "1", + }); + }); + + it("rejects a non-governance identifier", () => { + expect(parseIdentifier("bitvid:admin:blacklist")).toBeNull(); + expect(parseIdentifier("")).toBeNull(); + }); +}); + +describe("isCanonicalGovernanceEvent", () => { + it("accepts a well-formed document", () => { + expect( + isCanonicalGovernanceEvent(event({ tags: [["d", canonicalIdentifier("app", "user-deny")]] })), + ).toBe(true); + }); + + it("rejects the wrong kind", () => { + expect( + isCanonicalGovernanceEvent( + event({ kind: 30000, tags: [["d", canonicalIdentifier("app", "user-deny")]] }), + ), + ).toBe(false); + }); + + it("can be restricted to one namespace", () => { + const candidate = event({ tags: [["d", canonicalIdentifier("app", "user-deny")]] }); + expect(isCanonicalGovernanceEvent(candidate, "app")).toBe(true); + expect(isCanonicalGovernanceEvent(candidate, "other")).toBe(false); + }); +}); + +describe("decodeContribution", () => { + it("decodes a user denial list", () => { + const contribution = decodeContribution( + event({ + tags: [ + ["d", canonicalIdentifier("app", "user-deny")], + ["p", CREATOR], + ], + }), + ); + expect(contribution).toMatchObject({ + actor: MODERATOR, + kind: "user-deny", + targets: [{ type: "user", pubkey: CREATOR }], + }); + }); + + it("decodes an event denial list", () => { + const contribution = decodeContribution( + event({ + tags: [ + ["d", canonicalIdentifier("app", "event-deny")], + ["e", EVENT_ID], + ], + }), + ); + expect(contribution?.targets).toEqual([{ type: "event", id: EVENT_ID }]); + }); + + it("decodes an address denial list", () => { + const contribution = decodeContribution( + event({ + tags: [ + ["d", canonicalIdentifier("app", "address-deny")], + ["a", `30078:${CREATOR}:sku-001`], + ], + }), + ); + expect(contribution?.targets).toEqual([ + { type: "address", kind: "30078", pubkey: CREATOR, identifier: "sku-001" }, + ]); + }); + + it("carries a community source marker", () => { + const contribution = decodeContribution( + event({ + tags: [ + ["d", canonicalIdentifier("app", "user-deny")], + ["source", "list-a"], + ["p", CREATOR], + ], + }), + ); + expect(contribution?.source).toBe("list-a"); + }); + + it("skips malformed entries but keeps the rest", () => { + const contribution = decodeContribution( + event({ + tags: [ + ["d", canonicalIdentifier("app", "user-deny")], + ["p", "not-a-pubkey"], + ["p", CREATOR], + ], + }), + ); + expect(contribution?.targets).toHaveLength(1); + }); + + it("returns null for an unknown scope", () => { + expect( + decodeContribution(event({ tags: [["d", canonicalIdentifier("app", "nonsense")]] })), + ).toBeNull(); + }); + + it("prefers an explicit scope tag over the identifier", () => { + const contribution = decodeContribution( + event({ + tags: [ + ["d", canonicalIdentifier("app", "user-deny")], + ["scope", "user-allow"], + ["p", CREATOR], + ], + }), + ); + expect(contribution?.kind).toBe("user-allow"); + }); +}); + +describe("address coordinates", () => { + it("decodes a coordinate", () => { + expect(decodeAddressCoordinate(`30078:${CREATOR}:sku-001`)).toEqual({ + type: "address", + kind: "30078", + pubkey: CREATOR, + identifier: "sku-001", + }); + }); + + it("keeps colons inside the d-tag", () => { + expect(decodeAddressCoordinate(`30078:${CREATOR}:bitroad:product:sku-001`)?.identifier).toBe( + "bitroad:product:sku-001", + ); + }); + + it("rejects a non-numeric kind", () => { + expect(decodeAddressCoordinate(`abc:${CREATOR}:sku`)).toBeNull(); + }); + + it("rejects a missing identifier", () => { + expect(decodeAddressCoordinate(`30078:${CREATOR}:`)).toBeNull(); + }); + + it("round-trips", () => { + const coordinate = `30078:${CREATOR}:sku-001`; + const target = decodeAddressCoordinate(coordinate); + expect(encodeAddressCoordinate(/** @type {any} */ (target))).toBe(coordinate); + }); +}); + +describe("decodeRoles", () => { + const rolesEvent = event({ + pubkey: ROOT, + tags: [ + ["d", canonicalIdentifier("app", "roles")], + ["p", MODERATOR, "moderator"], + ["cap", MODERATOR, "contribute-address-deny"], + ["protected", CREATOR], + ], + }); + + it("treats the publisher as root", () => { + expect(decodeRoles(rolesEvent)?.root).toBe(ROOT); + }); + + it("reads role assignments", () => { + expect(decodeRoles(rolesEvent)?.actors[MODERATOR]).toEqual(["moderator"]); + }); + + it("reads explicit capabilities", () => { + expect(decodeRoles(rolesEvent)?.capabilities[MODERATOR]).toEqual(["contribute-address-deny"]); + }); + + it("always protects the root and reads extra protected actors", () => { + const roles = decodeRoles(rolesEvent); + expect(roles?.protectedActors).toContain(ROOT); + expect(roles?.protectedActors).toContain(CREATOR); + }); + + it("returns null for a non-roles document", () => { + expect(decodeRoles(event({ tags: [["d", canonicalIdentifier("app", "user-deny")]] }))).toBeNull(); + }); +}); + +describe("decodePolicy", () => { + const policyEvent = (content) => + event({ tags: [["d", canonicalIdentifier("app", "policy")]], content }); + + it("parses valid JSON content", () => { + expect(decodePolicy(policyEvent('{"version":"1"}'))).toEqual({ version: "1" }); + }); + + it("returns null for malformed JSON rather than throwing", () => { + expect(decodePolicy(policyEvent("{not json"))).toBeNull(); + }); + + it("rejects non-object content", () => { + expect(decodePolicy(policyEvent("[1,2]"))).toBeNull(); + expect(decodePolicy(policyEvent('"a string"'))).toBeNull(); + }); + + it("rejects oversized content before parsing", () => { + expect(decodePolicy(policyEvent("x".repeat(MAX_POLICY_BYTES + 1)))).toBeNull(); + }); + + it("rejects empty content", () => { + expect(decodePolicy(policyEvent(""))).toBeNull(); + }); +}); + +describe("encoders", () => { + it("encodes a contribution round-trip", () => { + const template = encodeContribution( + { actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }, + "app", + ); + const decoded = decodeContribution(event({ ...template, pubkey: MODERATOR })); + expect(decoded?.kind).toBe("user-deny"); + expect(decoded?.targets).toEqual([{ type: "user", pubkey: CREATOR }]); + }); + + it("encodes an address contribution", () => { + const template = encodeContribution( + { + actor: MODERATOR, + kind: "address-deny", + targets: [{ type: "address", kind: "30078", pubkey: CREATOR, identifier: "sku" }], + }, + "app", + ); + expect(template.tags).toContainEqual(["a", `30078:${CREATOR}:sku`]); + }); + + it("encodes roles round-trip", () => { + const template = encodeRoles( + { actors: { [MODERATOR]: ["moderator"] }, capabilities: {}, protectedActors: [] }, + "app", + ); + const decoded = decodeRoles(event({ ...template, pubkey: ROOT })); + expect(decoded?.actors[MODERATOR]).toEqual(["moderator"]); + }); + + it("stamps the client and version tags", () => { + const template = encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [] }, "app"); + expect(template.tags).toContainEqual(["v", "1"]); + expect(template.tags).toContainEqual(["client", "nostr-governance"]); + }); +}); diff --git a/packages/nostr/test/legacy.test.js b/packages/nostr/test/legacy.test.js new file mode 100644 index 0000000..55c9b41 --- /dev/null +++ b/packages/nostr/test/legacy.test.js @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; + +import { decodeContribution } from "../src/canonical.js"; +import { + LEGACY_COMMUNITY_SOURCES_IDENTIFIER, + LEGACY_EDITORS_IDENTIFIER, + LEGACY_KIND, + decodeLegacyCommunitySources, + decodeLegacyEditors, + decodeLegacyList, + decodeMixedContributions, + isLegacyAdminEvent, +} from "../src/legacy.js"; + +const ROOT = "a1".repeat(32); +const CURATOR = "c3".repeat(32); +const CREATOR = "d4".repeat(32); +const EVENT_ID = "1b".repeat(32); +const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; +const NPUB_HEX = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + +/** + * @param {Partial} parts + * @returns {import('../src/replaceable.js').NostrEvent} + */ +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: ROOT, + kind: LEGACY_KIND, + created_at: 1000, + tags: [], + content: "", + ...parts, +}); + +describe("isLegacyAdminEvent", () => { + it("recognizes the known legacy lists", () => { + for (const identifier of [ + "bitvid:admin:whitelist", + "bitvid:admin:blacklist", + "bitvid:admin:event-blacklist", + LEGACY_EDITORS_IDENTIFIER, + LEGACY_COMMUNITY_SOURCES_IDENTIFIER, + ]) { + expect(isLegacyAdminEvent(event({ tags: [["d", identifier]] }))).toBe(true); + } + }); + + it("rejects unrelated lists", () => { + expect(isLegacyAdminEvent(event({ tags: [["d", "bitvid:playlist"]] }))).toBe(false); + }); + + it("rejects the wrong kind", () => { + expect(isLegacyAdminEvent(event({ kind: 30078, tags: [["d", "bitvid:admin:blacklist"]] }))).toBe( + false, + ); + }); +}); + +describe("decodeLegacyList", () => { + it("maps the blacklist onto a user denial", () => { + const contribution = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] }), + ); + expect(contribution).toMatchObject({ + actor: ROOT, + kind: "user-deny", + targets: [{ type: "user", pubkey: CREATOR }], + }); + }); + + it("maps the whitelist onto a user allowance", () => { + const contribution = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:whitelist"], ["p", CREATOR]] }), + ); + expect(contribution?.kind).toBe("user-allow"); + }); + + it("maps the event blacklist onto an event denial", () => { + const contribution = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:event-blacklist"], ["e", EVENT_ID]] }), + ); + expect(contribution?.targets).toEqual([{ type: "event", id: EVENT_ID }]); + }); + + it("normalizes npub entries to hex", () => { + const contribution = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB]] }), + ); + expect(contribution?.targets).toEqual([{ type: "user", pubkey: NPUB_HEX }]); + }); + + it("treats hex and npub entries as the same target", () => { + const fromNpub = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB]] }), + ); + const fromHex = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB_HEX]] }), + ); + expect(fromNpub?.targets).toEqual(fromHex?.targets); + }); + + it("returns null for the editor roster", () => { + expect(decodeLegacyList(event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER]] }))).toBeNull(); + }); + + it("returns null for an unknown identifier", () => { + expect(decodeLegacyList(event({ tags: [["d", "bitvid:other"]] }))).toBeNull(); + }); + + it("skips malformed entries", () => { + const contribution = decodeLegacyList( + event({ tags: [["d", "bitvid:admin:blacklist"], ["p", "junk"], ["p", CREATOR]] }), + ); + expect(contribution?.targets).toHaveLength(1); + }); +}); + +describe("decodeLegacyEditors", () => { + it("returns editors as plain pubkeys", () => { + expect( + decodeLegacyEditors(event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER], ["p", CREATOR]] })), + ).toEqual([CREATOR]); + }); + + it("deduplicates", () => { + expect( + decodeLegacyEditors( + event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER], ["p", CREATOR], ["p", CREATOR]] }), + ), + ).toHaveLength(1); + }); + + it("returns nothing for other lists", () => { + expect(decodeLegacyEditors(event({ tags: [["d", "bitvid:admin:blacklist"]] }))).toEqual([]); + }); +}); + +describe("decodeLegacyCommunitySources", () => { + it("decodes curator list references", () => { + const sources = decodeLegacyCommunitySources( + event({ + tags: [ + ["d", LEGACY_COMMUNITY_SOURCES_IDENTIFIER], + ["a", `30000:${CURATOR}:bitvid:admin:blacklist`], + ], + }), + ); + expect(sources).toEqual([ + { curator: CURATOR, identifier: "bitvid:admin:blacklist", kind: 30000 }, + ]); + }); + + it("skips malformed coordinates", () => { + const sources = decodeLegacyCommunitySources( + event({ tags: [["d", LEGACY_COMMUNITY_SOURCES_IDENTIFIER], ["a", "garbage"]] }), + ); + expect(sources).toEqual([]); + }); + + it("returns nothing for other lists", () => { + expect(decodeLegacyCommunitySources(event({ tags: [["d", "bitvid:admin:blacklist"]] }))).toEqual( + [], + ); + }); +}); + +describe("decodeMixedContributions", () => { + it("reads legacy and canonical events side by side", () => { + const legacy = event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] }); + const canonical = event({ + kind: 30078, + tags: [["d", "app:governance:user-deny:v1"], ["p", CREATOR]], + }); + + const contributions = decodeMixedContributions([legacy, canonical], decodeContribution); + expect(contributions).toHaveLength(2); + expect(contributions.every((entry) => entry.kind === "user-deny")).toBe(true); + }); + + it("ignores events neither codec recognizes", () => { + expect(decodeMixedContributions([event({ tags: [["d", "unknown"]] })], decodeContribution)).toEqual( + [], + ); + }); +}); diff --git a/packages/nostr/test/mutes.test.js b/packages/nostr/test/mutes.test.js new file mode 100644 index 0000000..16bb134 --- /dev/null +++ b/packages/nostr/test/mutes.test.js @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { + MUTE_LIST_KIND, + decodeMuteList, + encodeMuteList, + extractMuteCategory, + toMuteRecords, +} from "../src/mutes.js"; + +const OWNER = "a1".repeat(32); +const OTHER_OWNER = "b2".repeat(32); +const CREATOR = "d4".repeat(32); + +/** + * @param {Partial} parts + * @returns {import('../src/replaceable.js').NostrEvent} + */ +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: OWNER, + kind: MUTE_LIST_KIND, + created_at: 1000, + tags: [], + content: "", + ...parts, +}); + +describe("extractMuteCategory", () => { + it("reads index 3", () => { + expect(extractMuteCategory(["p", CREATOR, "wss://relay.example", "Spam"])).toBe("spam"); + }); + + it("falls back to index 2 when it is not a relay", () => { + expect(extractMuteCategory(["p", CREATOR, "harassment"])).toBe("harassment"); + }); + + it("ignores a relay hint at index 2", () => { + expect(extractMuteCategory(["p", CREATOR, "wss://relay.example"])).toBe(""); + }); + + it("returns empty for a bare tag", () => { + expect(extractMuteCategory(["p", CREATOR])).toBe(""); + expect(extractMuteCategory(/** @type {any} */ (null))).toBe(""); + }); +}); + +describe("decodeMuteList", () => { + it("decodes public entries", () => { + const list = decodeMuteList(event({ tags: [["p", CREATOR]] })); + expect(list?.owner).toBe(OWNER); + expect(list?.entries).toEqual([{ pubkey: CREATOR }]); + }); + + it("carries the list timestamp for window expiry", () => { + expect(decodeMuteList(event({ created_at: 555, tags: [["p", CREATOR]] }))?.updatedAt).toBe(555); + }); + + it("attaches categories", () => { + const list = decodeMuteList(event({ tags: [["p", CREATOR, "", "spam"]] })); + expect(list?.entries[0]).toEqual({ pubkey: CREATOR, category: "spam" }); + }); + + it("deduplicates repeated pubkeys", () => { + const list = decodeMuteList(event({ tags: [["p", CREATOR], ["p", CREATOR]] })); + expect(list?.entries).toHaveLength(1); + }); + + it("skips malformed pubkeys", () => { + const list = decodeMuteList(event({ tags: [["p", "short"], ["p", CREATOR]] })); + expect(list?.entries).toHaveLength(1); + }); + + it("flags encrypted entries without decoding them", () => { + const list = decodeMuteList(event({ content: "encrypted-blob", tags: [["p", CREATOR]] })); + expect(list?.hasEncryptedEntries).toBe(true); + expect(list?.entries).toHaveLength(1); + }); + + it("rejects the wrong kind", () => { + expect(decodeMuteList(event({ kind: 30000 }))).toBeNull(); + }); + + it("rejects a malformed owner", () => { + expect(decodeMuteList(event({ pubkey: "nope" }))).toBeNull(); + }); +}); + +describe("toMuteRecords", () => { + it("keys records by governance target", () => { + const lists = [decodeMuteList(event({ tags: [["p", CREATOR]] }))]; + const records = toMuteRecords(/** @type {any} */ (lists)); + expect(records.get(`user:${CREATOR}`)).toEqual([{ muter: OWNER, updatedAt: 1000 }]); + }); + + it("collects distinct muters for one target", () => { + const lists = [ + decodeMuteList(event({ tags: [["p", CREATOR]] })), + decodeMuteList(event({ pubkey: OTHER_OWNER, tags: [["p", CREATOR]] })), + ]; + expect(toMuteRecords(/** @type {any} */ (lists)).get(`user:${CREATOR}`)).toHaveLength(2); + }); + + it("preserves categories", () => { + const lists = [decodeMuteList(event({ tags: [["p", CREATOR, "", "spam"]] }))]; + expect(toMuteRecords(/** @type {any} */ (lists)).get(`user:${CREATOR}`)?.[0].category).toBe("spam"); + }); + + it("tolerates an empty input", () => { + expect(toMuteRecords([]).size).toBe(0); + }); +}); + +describe("encodeMuteList", () => { + it("round-trips entries", () => { + const template = encodeMuteList([{ pubkey: CREATOR, category: "spam" }]); + const list = decodeMuteList(event(template)); + expect(list?.entries[0]).toEqual({ pubkey: CREATOR, category: "spam" }); + }); + + it("encodes a bare entry without a category slot", () => { + expect(encodeMuteList([{ pubkey: CREATOR }]).tags).toEqual([["p", CREATOR]]); + }); +}); diff --git a/packages/nostr/test/nip19.test.js b/packages/nostr/test/nip19.test.js new file mode 100644 index 0000000..0cc09ea --- /dev/null +++ b/packages/nostr/test/nip19.test.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + bech32Decode, + convertBits, + decodeNote, + decodeNpub, + normalizeEventIdInput, + normalizePubkeyInput, +} from "../src/nip19.js"; + +// Known-good vector from NIP-19. +const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; +const NPUB_HEX = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + +describe("bech32Decode", () => { + it("decodes a valid npub", () => { + const decoded = bech32Decode(NPUB); + expect(decoded?.hrp).toBe("npub"); + expect(decoded?.words.length).toBeGreaterThan(0); + }); + + it("rejects a bad checksum", () => { + expect(bech32Decode(`${NPUB.slice(0, -1)}q`)).toBeNull(); + }); + + it("rejects mixed case", () => { + expect(bech32Decode("npub1ABC")).toBeNull(); + }); + + it("rejects characters outside the charset", () => { + expect(bech32Decode("npub1bbbbbbbbbbbb")).toBeNull(); + }); + + it("rejects empty and non-string input", () => { + expect(bech32Decode("")).toBeNull(); + expect(bech32Decode(/** @type {any} */ (null))).toBeNull(); + }); + + it("rejects a string with no separator", () => { + expect(bech32Decode("npubqqqqqq")).toBeNull(); + }); +}); + +describe("convertBits", () => { + it("regroups 5-bit words into bytes", () => { + expect(convertBits([31, 31], 5, 8, true)).toEqual([255, 192]); + }); + + it("rejects values wider than fromBits", () => { + expect(convertBits([32], 5, 8, true)).toBeNull(); + }); + + it("rejects padding when padding is disallowed", () => { + expect(convertBits([1], 5, 8, false)).toBeNull(); + }); +}); + +describe("decodeNpub", () => { + it("decodes to the expected hex", () => { + expect(decodeNpub(NPUB)).toBe(NPUB_HEX); + }); + + it("returns empty for a note", () => { + expect(decodeNpub("note1qqqqq")).toBe(""); + }); + + it("returns empty for garbage", () => { + expect(decodeNpub("not-an-npub")).toBe(""); + expect(decodeNpub(/** @type {any} */ (undefined))).toBe(""); + }); +}); + +describe("decodeNote", () => { + it("returns empty for an npub", () => { + expect(decodeNote(NPUB)).toBe(""); + }); +}); + +describe("normalizePubkeyInput", () => { + it("passes hex through, lowercased", () => { + expect(normalizePubkeyInput(NPUB_HEX.toUpperCase())).toBe(NPUB_HEX); + }); + + it("decodes npub", () => { + expect(normalizePubkeyInput(NPUB)).toBe(NPUB_HEX); + }); + + it("trims surrounding whitespace", () => { + expect(normalizePubkeyInput(` ${NPUB_HEX} `)).toBe(NPUB_HEX); + }); + + it("rejects a short hex string", () => { + expect(normalizePubkeyInput("abc")).toBe(""); + }); + + it("normalizes hex and npub to the same value", () => { + expect(normalizePubkeyInput(NPUB)).toBe(normalizePubkeyInput(NPUB_HEX)); + }); +}); + +describe("normalizeEventIdInput", () => { + it("passes hex through", () => { + const id = "1b".repeat(32); + expect(normalizeEventIdInput(id.toUpperCase())).toBe(id); + }); + + it("rejects an npub", () => { + expect(normalizeEventIdInput(NPUB)).toBe(""); + }); +}); diff --git a/packages/nostr/test/replaceable.test.js b/packages/nostr/test/replaceable.test.js new file mode 100644 index 0000000..ce27653 --- /dev/null +++ b/packages/nostr/test/replaceable.test.js @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { + coordinateOf, + getTagValue, + getTags, + selectLatest, + selectReplaceable, + verifyEvents, +} from "../src/replaceable.js"; + +const PUBKEY = "a1".repeat(32); + +/** + * @param {Partial} parts + * @returns {import('../src/replaceable.js').NostrEvent} + */ +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: PUBKEY, + kind: 30078, + created_at: 1000, + tags: [], + content: "", + ...parts, +}); + +describe("tag helpers", () => { + it("reads the first value of a tag", () => { + expect(getTagValue(event({ tags: [["d", "x"], ["d", "y"]] }), "d")).toBe("x"); + }); + + it("returns empty for a missing tag", () => { + expect(getTagValue(event({}), "d")).toBe(""); + }); + + it("ignores malformed tags", () => { + expect(getTagValue(event({ tags: [/** @type {any} */ ("d"), ["d", "ok"]] }), "d")).toBe("ok"); + }); + + it("collects repeated tags", () => { + expect(getTags(event({ tags: [["p", "a"], ["p", "b"], ["e", "c"]] }), "p")).toHaveLength(2); + }); +}); + +describe("coordinateOf", () => { + it("builds kind:pubkey:d", () => { + expect(coordinateOf(event({ tags: [["d", "list"]] }))).toBe(`30078:${PUBKEY}:list`); + }); + + it("handles a missing d tag", () => { + expect(coordinateOf(event({}))).toBe(`30078:${PUBKEY}:`); + }); +}); + +describe("selectReplaceable", () => { + it("keeps the newest event per coordinate", () => { + const older = event({ id: "aa".repeat(32), created_at: 100, tags: [["d", "list"]] }); + const newer = event({ id: "bb".repeat(32), created_at: 200, tags: [["d", "list"]] }); + const winners = selectReplaceable([older, newer]); + expect(winners.get(`30078:${PUBKEY}:list`)?.id).toBe(newer.id); + }); + + it("is order-independent", () => { + const older = event({ id: "aa".repeat(32), created_at: 100, tags: [["d", "list"]] }); + const newer = event({ id: "bb".repeat(32), created_at: 200, tags: [["d", "list"]] }); + expect(selectReplaceable([older, newer]).get(`30078:${PUBKEY}:list`)?.id).toBe( + selectReplaceable([newer, older]).get(`30078:${PUBKEY}:list`)?.id, + ); + }); + + it("breaks same-timestamp ties deterministically", () => { + const a = event({ id: "aa".repeat(32), created_at: 100, tags: [["d", "list"]] }); + const b = event({ id: "bb".repeat(32), created_at: 100, tags: [["d", "list"]] }); + expect(selectReplaceable([a, b]).get(`30078:${PUBKEY}:list`)?.id).toBe(a.id); + expect(selectReplaceable([b, a]).get(`30078:${PUBKEY}:list`)?.id).toBe(a.id); + }); + + it("keeps distinct coordinates apart", () => { + const first = event({ tags: [["d", "one"]] }); + const second = event({ tags: [["d", "two"]] }); + expect(selectReplaceable([first, second]).size).toBe(2); + }); + + it("ignores malformed events", () => { + expect(selectReplaceable([/** @type {any} */ (null), /** @type {any} */ ({})]).size).toBe(0); + }); +}); + +describe("selectLatest", () => { + it("returns the newest event", () => { + const older = event({ id: "aa".repeat(32), created_at: 1 }); + const newer = event({ id: "bb".repeat(32), created_at: 2 }); + expect(selectLatest([older, newer])?.id).toBe(newer.id); + }); + + it("returns null for an empty list", () => { + expect(selectLatest([])).toBeNull(); + }); +}); + +describe("verifyEvents", () => { + it("passes everything through when no verifier is supplied", async () => { + const events = [event({}), event({})]; + expect(await verifyEvents(events)).toHaveLength(2); + }); + + it("drops events the verifier rejects", async () => { + const keep = event({ id: "aa".repeat(32) }); + const drop = event({ id: "bb".repeat(32) }); + const verified = await verifyEvents([keep, drop], (candidate) => candidate.id === keep.id); + expect(verified).toEqual([keep]); + }); + + it("treats a throwing verifier as a rejection", async () => { + const verified = await verifyEvents([event({})], () => { + throw new Error("boom"); + }); + expect(verified).toEqual([]); + }); + + it("supports async verifiers", async () => { + const verified = await verifyEvents([event({})], async () => true); + expect(verified).toHaveLength(1); + }); +}); diff --git a/packages/nostr/test/reports.test.js b/packages/nostr/test/reports.test.js new file mode 100644 index 0000000..bd8c0ec --- /dev/null +++ b/packages/nostr/test/reports.test.js @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { + REPORT_KIND, + decodeReport, + encodeReport, + extractReportType, + isRelayHint, + normalizeCategory, +} from "../src/reports.js"; + +const REPORTER = "a1".repeat(32); +const CREATOR = "d4".repeat(32); +const EVENT_ID = "1b".repeat(32); + +/** + * @param {Partial} parts + * @returns {import('../src/replaceable.js').NostrEvent} + */ +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: REPORTER, + kind: REPORT_KIND, + created_at: 1000, + tags: [], + content: "", + ...parts, +}); + +describe("isRelayHint", () => { + it("detects relay URLs", () => { + expect(isRelayHint("wss://relay.example")).toBe(true); + expect(isRelayHint("https://relay.example")).toBe(true); + }); + + it("does not treat a category as a relay", () => { + expect(isRelayHint("spam")).toBe(false); + expect(isRelayHint(undefined)).toBe(false); + }); +}); + +describe("normalizeCategory", () => { + it("lowercases and trims", () => { + expect(normalizeCategory(" SPAM ")).toBe("spam"); + }); + + it("returns empty for non-strings", () => { + expect(normalizeCategory(null)).toBe(""); + }); +}); + +describe("extractReportType", () => { + it("prefers an explicit report tag", () => { + expect(extractReportType(event({ tags: [["report", "nudity"], ["t", "spam"]] }))).toBe("nudity"); + }); + + it("accepts a type tag", () => { + expect(extractReportType(event({ tags: [["type", "malware"]] }))).toBe("malware"); + }); + + it("falls back to the matching target tag", () => { + expect(extractReportType(event({ tags: [["e", EVENT_ID, "spam"]] }), EVENT_ID)).toBe("spam"); + }); + + it("ignores a relay hint in the type position", () => { + expect(extractReportType(event({ tags: [["e", EVENT_ID, "wss://relay.example"]] }), EVENT_ID)).toBe( + "", + ); + }); + + it("falls back to a t hashtag", () => { + expect(extractReportType(event({ tags: [["t", "impersonation"]] }))).toBe("impersonation"); + }); + + it("returns empty when nothing names a type", () => { + expect(extractReportType(event({ tags: [["e", EVENT_ID]] }), EVENT_ID)).toBe(""); + }); +}); + +describe("decodeReport", () => { + it("decodes an event report", () => { + const [report] = decodeReport(event({ tags: [["e", EVENT_ID, "spam"]] })); + expect(report).toEqual({ + reporter: REPORTER, + target: { type: "event", id: EVENT_ID }, + category: "spam", + createdAt: 1000, + }); + }); + + it("decodes a user report", () => { + const [report] = decodeReport(event({ tags: [["p", CREATOR, "impersonation"]] })); + expect(report.target).toEqual({ type: "user", pubkey: CREATOR }); + }); + + it("prefers the event target when both are present", () => { + const reports = decodeReport( + event({ tags: [["report", "spam"], ["e", EVENT_ID], ["p", CREATOR]] }), + ); + expect(reports).toHaveLength(1); + expect(reports[0].target.type).toBe("event"); + }); + + it("decodes multiple event targets", () => { + const other = "2c".repeat(32); + const reports = decodeReport( + event({ tags: [["report", "spam"], ["e", EVENT_ID], ["e", other]] }), + ); + expect(reports).toHaveLength(2); + }); + + it("rejects the wrong kind", () => { + expect(decodeReport(event({ kind: 1 }))).toEqual([]); + }); + + it("skips a report with no resolvable category", () => { + expect(decodeReport(event({ tags: [["e", EVENT_ID]] }))).toEqual([]); + }); + + it("skips malformed target identifiers", () => { + expect(decodeReport(event({ tags: [["report", "spam"], ["e", "short"]] }))).toEqual([]); + }); + + it("rejects a malformed reporter", () => { + expect(decodeReport(event({ pubkey: "nope", tags: [["e", EVENT_ID, "spam"]] }))).toEqual([]); + }); +}); + +describe("encodeReport", () => { + it("encodes an event report round-trip", () => { + const template = encodeReport({ type: "event", id: EVENT_ID, author: CREATOR }, "spam"); + const [decoded] = decodeReport(event(template)); + expect(decoded.category).toBe("spam"); + expect(decoded.target).toEqual({ type: "event", id: EVENT_ID }); + }); + + it("includes the author as a p tag for event reports", () => { + const template = encodeReport({ type: "event", id: EVENT_ID, author: CREATOR }, "spam"); + expect(template.tags).toContainEqual(["p", CREATOR]); + }); + + it("encodes a user report", () => { + const template = encodeReport({ type: "user", pubkey: CREATOR }, "impersonation"); + expect(template.tags).toContainEqual(["p", CREATOR, "impersonation"]); + }); + + it("encodes an address report", () => { + const template = encodeReport( + { type: "address", kind: "30078", pubkey: CREATOR, identifier: "sku" }, + "scam", + ); + expect(template.tags).toContainEqual(["a", `30078:${CREATOR}:sku`, "scam"]); + }); + + it("rejects an empty category", () => { + expect(() => encodeReport({ type: "user", pubkey: CREATOR }, " ")).toThrow(/non-empty/); + }); +});