mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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.
This commit is contained in:
1 parent
4f689184e2
commit
5e0fffb32e
14 files changed
+2308
-2
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<string, Contribution["kind"]>} */
|
||||
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<string, string[]>, capabilities: Record<string, string[]>, 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<string, string[]>} */
|
||||
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<string, string[]>} */
|
||||
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<string, string[]>} [roster.actors]
|
||||
* @param {Record<string, string[]>} [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 };
|
||||
}
|
||||
@@ -1,2 +1,75 @@
|
||||
// Placeholder for nostr module exports
|
||||
export {};
|
||||
// @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
|
||||
*/
|
||||
@@ -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<string, Contribution["kind"]>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -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<string>} */
|
||||
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<string, Array<{ muter: string, category?: string, updatedAt: number }>>}
|
||||
*/
|
||||
export function toMuteRecords(lists) {
|
||||
/** @type {Map<string, Array<{ muter: string, category?: string, updatedAt: number }>>} */
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, number>} */
|
||||
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);
|
||||
}
|
||||
@@ -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<string, NostrEvent>} Coordinate to winning event
|
||||
*/
|
||||
export function selectReplaceable(events) {
|
||||
/** @type {Map<string, NostrEvent>} */
|
||||
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<boolean>} 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<NostrEvent[]>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<import('../src/replaceable.js').NostrEvent>} 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<import('../src/replaceable.js').NostrEvent>} 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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<import('../src/replaceable.js').NostrEvent>} 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]]);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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<import('../src/replaceable.js').NostrEvent>} 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);
|
||||
});
|
||||
});
|
||||
@@ -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<import('../src/replaceable.js').NostrEvent>} 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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user