diff --git a/src/lib/nip86.test.ts b/src/lib/nip86.test.ts new file mode 100644 index 0000000..3d0619b --- /dev/null +++ b/src/lib/nip86.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it, vi } from 'vitest'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import { NSecSigner } from '@nostrify/nostrify'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { + classifyMethod, + isBlockedIpList, + isEventRefList, + isKindList, + isPubkeyList, + Nip86Error, + nip86Call, + nip86Params, + normalizeRelayHttpUrl, + parseEventIdInput, + parseKindInput, + parsePubkeyInput, + parseRoles, + partitionMethods, + relayWsUrl, + sanitizeIconUrl, + validateIpInput, + validateReason, + validateRoleColor, + validateRoleId, +} from './nip86'; + +const HEX_A = 'a'.repeat(64); +const HEX_B = 'b'.repeat(64); + +describe('normalizeRelayHttpUrl', () => { + it('normalizes bare hosts and common schemes to https', () => { + expect(normalizeRelayHttpUrl('relay.example.com')).toBe('https://relay.example.com/'); + expect(normalizeRelayHttpUrl('wss://relay.example.com')).toBe('https://relay.example.com/'); + expect(normalizeRelayHttpUrl('https://relay.example.com/')).toBe('https://relay.example.com/'); + expect(normalizeRelayHttpUrl(' relay.example.com ')).toBe('https://relay.example.com/'); + }); + + it('keeps ports and paths', () => { + expect(normalizeRelayHttpUrl('wss://relay.example.com:8443/relay')).toBe( + 'https://relay.example.com:8443/relay', + ); + }); + + it('maps ws:// to http:// for local relays', () => { + expect(normalizeRelayHttpUrl('ws://localhost:7777')).toBe('http://localhost:7777/'); + expect(normalizeRelayHttpUrl('http://localhost:7777')).toBe('http://localhost:7777/'); + }); + + it('rejects garbage and non-web schemes', () => { + expect(normalizeRelayHttpUrl('')).toBeUndefined(); + expect(normalizeRelayHttpUrl(' ')).toBeUndefined(); + expect(normalizeRelayHttpUrl('ftp://relay.example.com')).toBeUndefined(); + expect(normalizeRelayHttpUrl('javascript:alert(1)')).toBeUndefined(); + }); + + it('derives the websocket URL for NIP-98 u tags', () => { + expect(relayWsUrl('https://relay.example.com/')).toBe('wss://relay.example.com/'); + expect(relayWsUrl('http://localhost:7777/')).toBe('ws://localhost:7777/'); + }); +}); + +describe('capability model', () => { + it('classifies core, passthrough and extension methods', () => { + expect(classifyMethod('supportedmethods')).toBe('core'); + expect(classifyMethod('banpubkey')).toBe('core'); + expect(classifyMethod('changerelayicon')).toBe('core'); + expect(classifyMethod('stat')).toBe('passthrough'); + expect(classifyMethod('purgeallevents')).toBe('extension'); + expect(classifyMethod('listroles')).toBe('extension'); + }); + + it('partitions advertised methods, keeping extensions separate', () => { + const { core, extensions } = partitionMethods([ + 'supportedmethods', + 'banpubkey', + 'stat', + 'purgeallevents', + ]); + expect(core).toContain('banpubkey'); + expect(core).toContain('stat'); + expect(extensions).toEqual(['purgeallevents']); + }); +}); + +describe('response validators', () => { + it('accepts well-shaped lists', () => { + expect(isPubkeyList([{ pubkey: HEX_A, reason: 'spam' }, { pubkey: HEX_B }])).toBe(true); + expect(isEventRefList([{ id: HEX_A }])).toBe(true); + expect(isBlockedIpList([{ ip: '203.0.113.7', reason: 'scanner' }])).toBe(true); + expect(isKindList([0, 1, 30023])).toBe(true); + }); + + it('rejects malformed lists', () => { + expect(isPubkeyList([{ pubkey: 3 }])).toBe(false); + expect(isPubkeyList([{ pubkey: HEX_A, reason: 42 }])).toBe(false); + expect(isEventRefList([{ id: HEX_A }, 'oops'])).toBe(false); + expect(isBlockedIpList([{ ip: null }])).toBe(false); + expect(isKindList([1, -1])).toBe(false); + expect(isKindList([1, 70000])).toBe(false); + expect(isKindList(['1'])).toBe(false); + expect(isPubkeyList('not-an-array')).toBe(false); + }); + + it('parses roles from both string and object shapes', () => { + expect(parseRoles(['admin', { id: 'mod', label: 'Moderator', order: 2 }])).toEqual([ + { id: 'admin' }, + { id: 'mod', label: 'Moderator', description: undefined, color: undefined, order: 2 }, + ]); + expect(parseRoles([{ name: 'legacy' }])).toEqual([ + { id: 'legacy', label: undefined, description: undefined, color: undefined, order: undefined }, + ]); + expect(parseRoles('nope')).toEqual([]); + expect(parseRoles([{}, { id: '' }, 42])).toEqual([]); + }); +}); + +describe('input validation', () => { + it('accepts hex pubkeys and decodes npub/nprofile', () => { + expect(parsePubkeyInput(HEX_A.toUpperCase())).toBe(HEX_A); + + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + expect(parsePubkeyInput(nip19.npubEncode(pubkey))).toBe(pubkey); + expect(parsePubkeyInput(nip19.nprofileEncode({ pubkey }))).toBe(pubkey); + }); + + it('rejects bad pubkeys', () => { + expect(parsePubkeyInput('')).toHaveProperty('error'); + expect(parsePubkeyInput('1234')).toHaveProperty('error'); + expect(parsePubkeyInput('npub1broken')).toHaveProperty('error'); + }); + + it('accepts hex event ids and decodes note/nevent', () => { + expect(parseEventIdInput(HEX_B)).toBe(HEX_B); + expect(parseEventIdInput(nip19.noteEncode(HEX_B))).toBe(HEX_B); + expect(parseEventIdInput(nip19.neventEncode({ id: HEX_B }))).toBe(HEX_B); + expect(parseEventIdInput('zzzz')).toHaveProperty('error'); + }); + + it('validates IPs and CIDR ranges', () => { + expect(validateIpInput('203.0.113.7')).toBeUndefined(); + expect(validateIpInput('203.0.113.0/24')).toBeUndefined(); + expect(validateIpInput('2001:db8::1')).toBeUndefined(); + expect(validateIpInput('2001:db8::/32')).toBeUndefined(); + + expect(validateIpInput('')).toBeTruthy(); + expect(validateIpInput('999.1.2.3')).toBeTruthy(); + expect(validateIpInput('10.0.0.1/33')).toBeTruthy(); + expect(validateIpInput('2001:db8::1/129')).toBeTruthy(); + expect(validateIpInput('not an ip')).toBeTruthy(); + }); + + it('validates kind numbers', () => { + expect(parseKindInput('1')).toBe(1); + expect(parseKindInput('30023')).toBe(30023); + expect(parseKindInput('0')).toBe(0); + expect(parseKindInput('')).toHaveProperty('error'); + expect(parseKindInput('-1')).toHaveProperty('error'); + expect(parseKindInput('65536')).toHaveProperty('error'); + expect(parseKindInput('abc')).toHaveProperty('error'); + }); + + it('validates role ids and colors', () => { + expect(validateRoleId('moderator')).toBeUndefined(); + expect(validateRoleId('')).toBeTruthy(); + expect(validateRoleId('two words')).toBeTruthy(); + expect(validateRoleId('x'.repeat(65))).toBeTruthy(); + + expect(validateRoleColor('')).toBeUndefined(); + expect(validateRoleColor('#8b5cf6')).toBeUndefined(); + expect(validateRoleColor('#fff')).toBeUndefined(); + expect(validateRoleColor('red')).toBeTruthy(); + }); + + it('sanitizes icon URLs', () => { + expect(sanitizeIconUrl('https://example.com/icon.png')).toBe('https://example.com/icon.png'); + expect(sanitizeIconUrl('javascript:alert(1)')).toHaveProperty('error'); + expect(sanitizeIconUrl('data:image/png;base64,xx')).toHaveProperty('error'); + expect(sanitizeIconUrl('')).toHaveProperty('error'); + expect(sanitizeIconUrl('not a url')).toHaveProperty('error'); + }); + + it('caps reason length', () => { + expect(validateReason('spam')).toBeUndefined(); + expect(validateReason('x'.repeat(501))).toBeTruthy(); + }); +}); + +describe('nip86Params', () => { + it('builds list params as empty arrays', () => { + expect(nip86Params('supportedmethods')).toEqual([]); + expect(nip86Params('listbannedpubkeys')).toEqual([]); + expect(nip86Params('listallowedkinds')).toEqual([]); + }); + + it('appends reasons only when present', () => { + expect(nip86Params('banpubkey', { pubkey: HEX_A, reason: ' spam ' })).toEqual([HEX_A, 'spam']); + expect(nip86Params('banpubkey', { pubkey: HEX_A, reason: ' ' })).toEqual([HEX_A]); + expect(nip86Params('unbanpubkey', { pubkey: HEX_A })).toEqual([HEX_A]); + }); + + it('never adds a reason to unblockip (NIP-86 defines none)', () => { + expect(nip86Params('unblockip', { ip: '203.0.113.7', reason: 'ignored' })).toEqual(['203.0.113.7']); + }); + + it('builds kind, role and presentation params', () => { + expect(nip86Params('allowkind', { kind: 4 })).toEqual([4]); + expect(nip86Params('createrole', { roleId: 'mod', role: { label: 'Mod', description: '', color: '#fff', order: 1 } })).toEqual([ + 'mod', + 'Mod', + '', + '#fff', + 1, + ]); + expect(nip86Params('deleterole', { roleId: 'mod' })).toEqual(['mod']); + expect(nip86Params('assignrole', { pubkey: HEX_A, roleId: 'mod' })).toEqual([HEX_A, 'mod']); + expect(nip86Params('changerelayname', { text: 'My Relay' })).toEqual(['My Relay']); + }); +}); + +describe('nip86Call', () => { + const url = 'https://relay.example.com/'; + const signer = new NSecSigner(generateSecretKey()); + + function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + it('POSTs JSON-RPC with a NIP-98 authorization header carrying u and payload tags', async () => { + const fetchFn = vi.fn(async () => jsonResponse({ result: ['supportedmethods'] })); + const result = await nip86Call(url, { signer, method: 'supportedmethods', fetchFn }); + + expect(result).toEqual(['supportedmethods']); + expect(fetchFn).toHaveBeenCalledOnce(); + + const [requestUrl, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]; + expect(requestUrl).toBe(url); + expect(init.method).toBe('POST'); + const headers = new Headers(init.headers); + expect(headers.get('Content-Type')).toBe('application/nostr+json+rpc'); + + const auth = headers.get('Authorization') ?? ''; + expect(auth.startsWith('Nostr ')).toBe(true); + + const { N64 } = await import('@nostrify/nostrify/utils'); + const event: NostrEvent = N64.decodeEvent(auth.slice('Nostr '.length)); + expect(event.kind).toBe(27235); + expect(event.tags.find(([name]) => name === 'u')?.[1]).toBe('wss://relay.example.com/'); + expect(event.tags.find(([name]) => name === 'method')?.[1]).toBe('POST'); + + const body = init.body as string; + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)); + const expectedPayload = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + expect(event.tags.find(([name]) => name === 'payload')?.[1]).toBe(expectedPayload); + expect(JSON.parse(body)).toEqual({ method: 'supportedmethods', params: [] }); + }); + + it('maps 401 and 403 to dedicated error codes', async () => { + const unauthorized = vi.fn(async () => jsonResponse({}, 401)); + await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: unauthorized })).rejects.toMatchObject({ + code: 'unauthorized', + status: 401, + }); + + const forbidden = vi.fn(async () => jsonResponse({}, 403)); + await expect(nip86Call(url, { signer, method: 'banpubkey', params: [HEX_A], fetchFn: forbidden })).rejects.toMatchObject({ + code: 'forbidden', + status: 403, + }); + }); + + it('treats non-JSON success responses as malformed (relay likely has no management API)', async () => { + const html = vi.fn( + async () => new Response('relay', { status: 200, headers: { 'Content-Type': 'text/html' } }), + ); + await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: html })).rejects.toMatchObject({ + code: 'malformed', + }); + }); + + it('surfaces relay error envelopes as rpc-error', async () => { + const failing = vi.fn(async () => jsonResponse({ error: 'not a manager' })); + await expect(nip86Call(url, { signer, method: 'banpubkey', params: [HEX_A], fetchFn: failing })).rejects.toMatchObject({ + code: 'rpc-error', + message: expect.stringContaining('not a manager'), + }); + }); + + it('maps network failures to unreachable', async () => { + const down = vi.fn(async () => { + throw new TypeError('fetch failed'); + }); + await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: down })).rejects.toMatchObject({ + code: 'unreachable', + }); + }); + + it('never puts secrets in error messages', async () => { + const refusing = { + getPublicKey: async () => HEX_A, + signEvent: async () => { + throw new Error('user rejected'); + }, + }; + try { + await nip86Call(url, { signer: refusing, method: 'supportedmethods' }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(Nip86Error); + expect((error as Nip86Error).code).toBe('signing-failed'); + expect((error as Nip86Error).message).not.toContain('Nostr '); + } + }); +}); diff --git a/src/lib/nip86.ts b/src/lib/nip86.ts new file mode 100644 index 0000000..9dbad33 --- /dev/null +++ b/src/lib/nip86.ts @@ -0,0 +1,640 @@ +import { N64 } from '@nostrify/nostrify/utils'; +import type { NostrSigner } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; + +/** + * NIP-86 "Relay Management API" client plumbing. + * + * NIP-86 is a *draft, optional* JSON-RPC-like protocol over plain HTTP(S), + * served on the same URI as the relay's WebSocket endpoint — it is not a + * WebSocket command protocol. Every request is a POST with the content type + * `application/nostr+json+rpc`, authorized with a NIP-98 event (kind 27235) + * whose `u` tag is the relay URL and which — unlike base NIP-98 — MUST carry + * a `payload` tag binding it to the request body. + * + * Implementations vary widely because the NIP is still a draft, so nothing + * here assumes more than `supportedmethods`: every other method is only ever + * called after the relay advertised it. + */ + +/** Every method name defined by the current NIP-86 draft. */ +export const NIP86_CORE_METHODS = [ + 'supportedmethods', + 'banpubkey', + 'unbanpubkey', + 'listbannedpubkeys', + 'allowpubkey', + 'unallowpubkey', + 'listallowedpubkeys', + 'listeventsneedingmoderation', + 'allowevent', + 'banevent', + 'listbannedevents', + 'listallowedkinds', + 'allowkind', + 'disallowkind', + 'blockip', + 'unblockip', + 'listblockedips', + 'createrole', + 'editrole', + 'deleterole', + 'assignrole', + 'unassignrole', + 'changerelayname', + 'changerelaydescription', + 'changerelayicon', +] as const; + +export type Nip86CoreMethod = (typeof NIP86_CORE_METHODS)[number]; + +const CORE_METHOD_SET = new Set(NIP86_CORE_METHODS); + +/** + * `stat` predates NIP-86 in several relay implementations and returns relay + * metadata rather than policy data, so it is *not* treated as a relay-specific + * management extension — it is a recognized non-management method and gets no + * console UI. + */ +const PASSTHROUGH_METHODS = new Set(['stat']); + +/** What an advertised method name means to this client. */ +export type Nip86MethodClass = 'core' | 'passthrough' | 'extension'; + +export function classifyMethod(name: string): Nip86MethodClass { + if (CORE_METHOD_SET.has(name)) return 'core'; + if (PASSTHROUGH_METHODS.has(name)) return 'passthrough'; + return 'extension'; +} + +/** + * Split an advertised method list into standard NIP-86 methods and + * relay-specific extensions. Unknown names are extensions and must be shown + * separately — never blended into the standard surface. + */ +export function partitionMethods(methods: string[]): { core: string[]; extensions: string[] } { + const core: string[] = []; + const extensions: string[] = []; + for (const name of methods) { + if (classifyMethod(name) === 'extension') extensions.push(name); + else core.push(name); + } + return { core, extensions }; +} + +/* -------------------------------------------------------------------------- + * Request and error plumbing + * ------------------------------------------------------------------------ */ + +export const NIP86_CONTENT_TYPE = 'application/nostr+json+rpc'; + +/** How long a single management request may take before it is aborted. */ +export const NIP86_TIMEOUT_MS = 10_000; + +export type Nip86ErrorCode = + | 'invalid-url' + | 'unreachable' + | 'http-error' + | 'unauthorized' + | 'forbidden' + | 'malformed' + | 'rpc-error' + | 'signing-failed' + | 'not-logged-in' + | 'unsupported'; + +/** + * An operator-facing error. `message` is safe to show in the UI and to copy + * into the audit log: it never contains the request body, the authorization + * header, or any key material. + */ +export class Nip86Error extends Error { + readonly code: Nip86ErrorCode; + /** HTTP status when the failure came from an HTTP response. */ + readonly status?: number; + + constructor(code: Nip86ErrorCode, message: string, status?: number) { + super(message); + this.name = 'Nip86Error'; + this.code = code; + this.status = status; + } +} + +export function isNip86Error(error: unknown): error is Nip86Error { + return error instanceof Nip86Error; +} + +/** + * Normalize operator input into the HTTP(S) endpoint NIP-86 speaks on. + * + * Accepts `relay.example.com`, `wss://` and `https://` forms; everything is + * canonicalized to `https://` (plain `ws://`/`http://` is kept only for local + * development relays). Returns `undefined` when the input cannot name a host. + */ +export function normalizeRelayHttpUrl(input: string): string | undefined { + const trimmed = input.trim(); + if (!trimmed) return undefined; + + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}`; + + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + return undefined; + } + + switch (parsed.protocol) { + case 'wss:': + parsed.protocol = 'https:'; + break; + case 'ws:': + parsed.protocol = 'http:'; + break; + case 'https:': + case 'http:': + break; + default: + return undefined; + } + + if (!parsed.hostname) return undefined; + return parsed.href; +} + +/** The WebSocket form of a normalized management endpoint, for NIP-98 `u` tags and NIP-11 fetches. */ +export function relayWsUrl(httpUrl: string): string { + const parsed = new URL(httpUrl); + parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:'; + return parsed.href; +} + +/** + * Sign a NIP-98 authorization header for one management request. + * + * The `u` tag must be the relay URL exactly as the relay knows it — the + * WebSocket form — and the `payload` tag the SHA-256 of the request body, as + * NIP-86 requires. The signed event never leaves this function except as a + * base64 token in the returned header value. + */ +export async function signNip86AuthHeader( + signer: NostrSigner, + httpUrl: string, + body: string, +): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)); + const payload = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + + const event = await signer.signEvent({ + kind: 27235, + content: '', + tags: [ + ['u', relayWsUrl(httpUrl)], + ['method', 'POST'], + ['payload', payload], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + return `Nostr ${N64.encodeEvent(event)}`; +} + +export interface Nip86CallOptions { + signer: NostrSigner; + method: string; + params?: unknown[]; + signal?: AbortSignal; + /** Injecting fetch keeps the client testable and the signing path honest. */ + fetchFn?: typeof fetch; +} + +/** + * Perform one NIP-86 management call. The response envelope is validated + * (`result` on success, `error` string on failure); anything else — including + * the HTML error pages many relays return for unknown content types — is a + * `malformed` error rather than a JSON exception. + */ +export async function nip86Call(httpUrl: string, options: Nip86CallOptions): Promise { + const { signer, method, params = [], signal, fetchFn = fetch } = options; + + const body = JSON.stringify({ method, params }); + + let authorization: string; + try { + authorization = await signNip86AuthHeader(signer, httpUrl, body); + } catch (error) { + throw new Nip86Error( + 'signing-failed', + `Your signer refused to authorize the request: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let response: Response; + try { + response = await fetchFn(httpUrl, { + method: 'POST', + headers: { + 'Content-Type': NIP86_CONTENT_TYPE, + Authorization: authorization, + }, + body, + signal: signal ?? AbortSignal.timeout(NIP86_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw new Nip86Error( + 'unreachable', + error instanceof DOMException && error.name === 'TimeoutError' + ? 'The relay did not answer in time.' + : 'The relay could not be reached. Check the URL and your connection.', + ); + } + + if (response.status === 401) { + throw new Nip86Error( + 'unauthorized', + 'The relay rejected the NIP-98 authorization (401). The signed-in key may not be a manager of this relay.', + 401, + ); + } + if (response.status === 403) { + throw new Nip86Error( + 'forbidden', + 'The relay authorized the request but forbids this operation for your key (403).', + 403, + ); + } + if (!response.ok) { + throw new Nip86Error('http-error', `The relay answered with HTTP ${response.status}.`, response.status); + } + + let envelope: unknown; + try { + envelope = await response.json(); + } catch { + throw new Nip86Error( + 'malformed', + 'The relay did not return a NIP-86 response. It may not speak the management API (or answered with a web page).', + response.status, + ); + } + + if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) { + throw new Nip86Error('malformed', 'The relay returned an unexpected response shape.', response.status); + } + + const { result, error } = envelope as { result?: unknown; error?: unknown }; + if (error !== undefined && error !== null) { + const detail = typeof error === 'string' ? error : 'unknown relay error'; + throw new Nip86Error('rpc-error', `The relay refused the operation: ${detail}`, response.status); + } + + return result as T; +} + +/* -------------------------------------------------------------------------- + * NIP-11 relay information document (identity shown after connecting) + * ------------------------------------------------------------------------ */ + +export interface RelayInfo { + name?: string; + description?: string; + pubkey?: string; + contact?: string; + software?: string; + version?: string; + icon?: string; +} + +/** + * Fetch the relay's NIP-11 information document. Returns `undefined` when the + * relay does not publish one — that says nothing about NIP-86 support, so it + * is never an error here. + */ +export async function fetchRelayInfo( + httpUrl: string, + opts?: { signal?: AbortSignal; fetchFn?: typeof fetch }, +): Promise { + try { + const response = await (opts?.fetchFn ?? fetch)(httpUrl, { + headers: { Accept: 'application/nostr+json' }, + signal: opts?.signal ?? AbortSignal.timeout(NIP86_TIMEOUT_MS), + }); + if (!response.ok) return undefined; + const doc: unknown = await response.json(); + if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) return undefined; + const record = doc as Record; + const pick = (key: keyof RelayInfo) => + typeof record[key] === 'string' ? (record[key] as string) : undefined; + return { + name: pick('name'), + description: pick('description'), + pubkey: pick('pubkey'), + contact: pick('contact'), + software: pick('software'), + version: pick('version'), + icon: pick('icon'), + }; + } catch { + return undefined; + } +} + +/* -------------------------------------------------------------------------- + * Response shapes (validated — relays are untrusted input) + * ------------------------------------------------------------------------ */ + +export interface ReasonedEntry { + reason?: string; +} + +export interface BannedPubkey extends ReasonedEntry { + pubkey: string; +} + +export interface AllowedPubkey extends ReasonedEntry { + pubkey: string; +} + +export interface ModeratedEventRef extends ReasonedEntry { + id: string; +} + +export interface BannedEvent extends ReasonedEntry { + id: string; +} + +export interface BlockedIp extends ReasonedEntry { + ip: string; +} + +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isReasonedEntry(value: unknown): value is ReasonedEntry { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const reason = (value as ReasonedEntry).reason; + return reason === undefined || typeof reason === 'string'; +} + +export function isPubkeyList(value: unknown): value is BannedPubkey[] { + return ( + Array.isArray(value) && + value.every( + (item) => isReasonedEntry(item) && typeof (item as BannedPubkey).pubkey === 'string', + ) + ); +} + +export function isEventRefList(value: unknown): value is ModeratedEventRef[] { + return ( + Array.isArray(value) && + value.every((item) => isReasonedEntry(item) && typeof (item as ModeratedEventRef).id === 'string') + ); +} + +export function isBlockedIpList(value: unknown): value is BlockedIp[] { + return ( + Array.isArray(value) && + value.every((item) => isReasonedEntry(item) && typeof (item as BlockedIp).ip === 'string') + ); +} + +export function isKindList(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.every((item) => Number.isInteger(item) && (item as number) >= 0 && (item as number) <= 65535) + ); +} + +/** A role as returned by role-listing extensions (`listroles` is not standard NIP-86). */ +export interface Nip86Role { + id: string; + label?: string; + description?: string; + color?: string; + order?: number; +} + +/** Tolerant parser for the various shapes relays give `listroles` results. */ +export function parseRoles(value: unknown): Nip86Role[] { + if (!Array.isArray(value)) return []; + + const roles: Nip86Role[] = []; + for (const item of value) { + if (typeof item === 'string' && item) { + roles.push({ id: item }); + continue; + } + if (typeof item !== 'object' || item === null || Array.isArray(item)) continue; + const record = item as Record; + const id = record.id ?? record.name; + if (typeof id !== 'string' || !id) continue; + roles.push({ + id, + label: typeof record.label === 'string' ? record.label : undefined, + description: typeof record.description === 'string' ? record.description : undefined, + color: typeof record.color === 'string' ? record.color : undefined, + order: typeof record.order === 'number' ? record.order : undefined, + }); + } + return roles; +} + +/* -------------------------------------------------------------------------- + * Input validation. Every validator returns an operator-safe message or + * undefined; nothing is signed or sent before its input passes. + * ------------------------------------------------------------------------ */ + +const HEX_64_RE = /^[0-9a-f]{64}$/i; + +/** + * Accept a 32-byte hex pubkey, or decode an `npub1…`/`nprofile1…` into one. + * Returns the canonical (lowercase) hex string or an error message. + */ +export function parsePubkeyInput(input: string): string | { error: string } { + const value = input.trim(); + if (!value) return { error: 'Enter a public key.' }; + if (HEX_64_RE.test(value)) return value.toLowerCase(); + + if (/^npub1|^nprofile1/i.test(value)) { + try { + const decoded = nip19.decode(value); + if (decoded.type === 'npub') return decoded.data; + if (decoded.type === 'nprofile') return decoded.data.pubkey; + } catch { + // fall through to the error below + } + return { error: 'That NIP-19 identifier could not be decoded.' }; + } + + return { error: 'A public key is 64 hex characters, or an npub1… / nprofile1… identifier.' }; +} + +/** Validate a 32-byte hex event id (or `note1…`/`nevent1…`). */ +export function parseEventIdInput(input: string): string | { error: string } { + const value = input.trim(); + if (!value) return { error: 'Enter an event ID.' }; + if (HEX_64_RE.test(value)) return value.toLowerCase(); + + if (/^note1|^nevent1/i.test(value)) { + try { + const decoded = nip19.decode(value); + if (decoded.type === 'note') return decoded.data; + if (decoded.type === 'nevent') return decoded.data.id; + } catch { + // fall through + } + return { error: 'That NIP-19 identifier could not be decoded.' }; + } + + return { error: 'An event ID is 64 hex characters, or a note1… / nevent1… identifier.' }; +} + +const IPV4_RE = + /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const IPV6_RE = /^[0-9a-f:]*:[0-9a-f:.]*$/i; + +/** + * Validate an IP address or CIDR range. Exactly what a relay accepts varies + * by implementation, so this only rules out values that cannot be an address. + */ +export function validateIpInput(input: string): string | undefined { + const value = input.trim(); + if (!value) return 'Enter an IP address or CIDR range.'; + + const [address, prefix, ...rest] = value.split('/'); + if (rest.length > 0) return 'Use a single “/” for a CIDR range, e.g. 203.0.113.0/24.'; + + const isV4 = IPV4_RE.test(address); + const isV6 = !isV4 && address.includes(':') && IPV6_RE.test(address); + if (!isV4 && !isV6) return 'That is not a valid IPv4 or IPv6 address.'; + + if (prefix !== undefined) { + if (!/^\d+$/.test(prefix)) return 'The CIDR prefix must be a number.'; + const size = Number(prefix); + const max = isV4 ? 32 : 128; + if (size < 0 || size > max) return `The CIDR prefix must be between 0 and ${max}.`; + } + + return undefined; +} + +/** Validate a Nostr kind number (0–65535 per NIP-01). */ +export function parseKindInput(input: string): number | { error: string } { + const value = input.trim(); + if (!/^\d+$/.test(value)) return { error: 'A kind is a whole number, e.g. 1 or 30023.' }; + const kind = Number(value); + if (kind < 0 || kind > 65535) return { error: 'Kinds range from 0 to 65535.' }; + return kind; +} + +/** Role ids go into RPC params verbatim; keep them slug-shaped and short. */ +export function validateRoleId(input: string): string | undefined { + const value = input.trim(); + if (!value) return 'Enter a role ID.'; + if (value.length > 64) return 'Role IDs must be 64 characters or fewer.'; + if (!/^[\w-]+$/.test(value)) return 'Use letters, numbers, dashes and underscores only.'; + return undefined; +} + +/** Hex color for role presentation, `#rgb`/`#rrggbb`, or empty. */ +export function validateRoleColor(input: string): string | undefined { + const value = input.trim(); + if (!value) return undefined; + return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) + ? undefined + : 'Use a hex color like #8b5cf6, or leave it empty.'; +} + +/** + * Validate and sanitize a relay icon URL before it is shown or submitted. + * Only https (and http for local relays) URLs survive — anything else could + * smuggle script into the page when rendered as an image. + */ +export function sanitizeIconUrl(input: string): string | { error: string } { + const value = input.trim(); + if (!value) return { error: 'Enter an icon URL.' }; + try { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return { error: 'Only https:// icon URLs are allowed.' }; + } + return parsed.href; + } catch { + return { error: 'That is not a valid URL.' }; + } +} + +/** A management payload is never longer than a sentence; cap reasons defensively. */ +export function validateReason(input: string): string | undefined { + return input.length <= 500 ? undefined : 'Keep the reason under 500 characters.'; +} + +/* -------------------------------------------------------------------------- + * Parameter builders — the single place that knows each method's signature. + * Reasons are only ever appended when the operator typed one. + * ------------------------------------------------------------------------ */ + +export function withReason(value: string, reason: string): unknown[] { + const trimmed = reason.trim(); + return trimmed ? [value, trimmed] : [value]; +} + +export function nip86Params( + method: Nip86CoreMethod, + input: { + pubkey?: string; + eventId?: string; + ip?: string; + kind?: number; + reason?: string; + roleId?: string; + role?: { label: string; description: string; color: string; order: number }; + text?: string; + } = {}, +): unknown[] { + const reason = input.reason?.trim() ?? ''; + + switch (method) { + case 'supportedmethods': + case 'listbannedpubkeys': + case 'listallowedpubkeys': + case 'listeventsneedingmoderation': + case 'listbannedevents': + case 'listallowedkinds': + case 'listblockedips': + return []; + case 'banpubkey': + case 'unbanpubkey': + case 'allowpubkey': + case 'unallowpubkey': + return withReason(input.pubkey ?? '', reason); + case 'allowevent': + case 'banevent': + return withReason(input.eventId ?? '', reason); + case 'blockip': + return withReason(input.ip ?? '', reason); + case 'unblockip': + return [input.ip ?? '']; + case 'allowkind': + case 'disallowkind': + return [input.kind ?? 0]; + case 'createrole': + case 'editrole': { + const role = input.role ?? { label: '', description: '', color: '', order: 0 }; + return [input.roleId ?? '', role.label, role.description, role.color, role.order]; + } + case 'deleterole': + return [input.roleId ?? '']; + case 'assignrole': + case 'unassignrole': + return [input.pubkey ?? '', input.roleId ?? '']; + case 'changerelayname': + case 'changerelaydescription': + case 'changerelayicon': + return [input.text ?? '']; + } +}