Add NIP-86 protocol client library with tests

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:32:27 +00:00
committed by GitHub
parent 0e470ed7cc
commit 38290bdaf2
2 changed files with 961 additions and 0 deletions

321
src/lib/nip86.test.ts Normal file
View File

@@ -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<typeof fetch>(async () => jsonResponse({ result: ['supportedmethods'] }));
const result = await nip86Call<string[]>(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<typeof fetch>(async () => jsonResponse({}, 401));
await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: unauthorized })).rejects.toMatchObject({
code: 'unauthorized',
status: 401,
});
const forbidden = vi.fn<typeof fetch>(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<typeof fetch>(
async () => new Response('<html>relay</html>', { 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<typeof fetch>(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<typeof fetch>(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 ');
}
});
});

640
src/lib/nip86.ts Normal file
View File

@@ -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<string>(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<string> {
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<T = unknown>(httpUrl: string, options: Nip86CallOptions): Promise<T> {
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<RelayInfo | undefined> {
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<string, unknown>;
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<string, unknown>;
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 (065535 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 ?? ''];
}
}