diff --git a/NIP.md b/NIP.md new file mode 100644 index 0000000..b52b94d --- /dev/null +++ b/NIP.md @@ -0,0 +1,29 @@ +# NIPs and custom schemas + +This project defines **no custom event kinds**. It implements and adopts the following +protocols: + +## Implemented NIPs + +- [NIP-86: Relay Management API](https://github.com/nostr-protocol/nips/blob/master/86.md) + (draft, optional) — the Relay Admin app (`src/apps/relay-admin/`) is a management client. + Requests are JSON-RPC-like POSTs over HTTP(S) on the relay's own URI with the + `application/nostr+json+rpc` content type, authorized with a NIP-98 event (kind 27235) + whose `u` tag is the relay URL and whose `payload` tag binds it to the request body. + The client treats `supportedmethods` as the source of truth and only calls methods the + relay advertised; advertised names outside the standard method list are treated as + relay-specific extensions and kept visually and semantically separate. No generic + event-purge/delete method is assumed — NIP-86 does not define one. +- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md) — used for + NIP-86 authorization (with the NIP-86-required `payload` tag) and for Blossom uploads. +- [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) + — read at connect time to show relay identity in Relay Admin. + +## Adopted third-party kinds + +- **Kind `777` ("Spell")** — a third-party draft NIP from the + [Grimoire](https://github.com/purrgrammer/grimoire) client, adopted as-is for interop. + See `docs/apps.md` ("Spells are a third-party kind") and `src/hooks/useSpells.ts`. + +Anything else (kinds 0, 1, 3, 5, 6, 16, 9802, 10002, 10003, 22242, 30023, 30311, 31337, +39701, …) follows the official NIPs as implemented in `src/hooks/` and `src/lib/`. diff --git a/docs/apps.md b/docs/apps.md index 5bf3981..7d554e2 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -102,6 +102,7 @@ export default function ExampleApp({ setTitle }: AppProps) { | Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat | | Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP | | Relays | `relays` | — | Connection state, subscription count, measured latency | +| Relay Admin | `relay-admin` | `relay?` | NIP-86 management console for relays you operate | | Settings | `settings` | — | Theme, relay list, Blossom servers, account, session | | About | `about` | — | What this is, the app list, the shortcuts | @@ -150,6 +151,23 @@ update, the same trap [follow lists](#follow-lists-are-a-whole-list-replacement) kind 3 replaces the entire contact list. The follow button therefore reads the current list back before writing, or the edit would silently drop everyone else. +### Relay Admin is capability-driven, not method-driven + +The Relay Admin app (`src/apps/relay-admin/`) implements [NIP-86](../NIP.md) — the draft, +optional relay-management API. Because implementations vary and the NIP is still a draft, +the console never assumes a method exists: it connects, authorizes with a NIP-98 event +(kind 27235, `u` + `payload` tags), calls `supportedmethods`, and renders only the +sections the relay advertised. Advertised names outside the standard list (e.g. a +relay-specific `purgeallevents` or `listroles`) land in a separate, clearly-labelled +extensions area that requires typed confirmation — they are never blended into the +standard surface, and the standard defines no generic event-purge/delete method. + +Destructive-but-reversible operations (bans, unbans, removing allowlist access, role +deletion) go through a confirmation dialog that states the target, the likely effect and +whether the relay offers a reverse operation. Every operation is written to a per-session +audit log (method, target, result, operator-safe error) that never contains the +authorization header or any key material; signing secrets stay inside the user's signer. + ### Relay latency is a real round trip A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE` diff --git a/src/hooks/useNip86.test.tsx b/src/hooks/useNip86.test.tsx new file mode 100644 index 0000000..be7db02 --- /dev/null +++ b/src/hooks/useNip86.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { generateSecretKey, nip19 } from 'nostr-tools'; + +import { TestApp } from '@/test/TestApp'; +import { useLoginActions } from './useLoginActions'; +import { useBannedPubkeys, useNip86Connection, useNip86Mutation, usePolicyMode } from './useNip86'; + +/** + * Integration tests for the NIP-86 session hook: discovery against a mocked + * relay endpoint, capability-gated lists, mutations, and the audit log. The + * network is stubbed at `fetch`; signing is real (nsec login + NSecSigner). + */ + +const PUBKEY_A = 'a'.repeat(64); + +function rpcResponse(result: unknown): Response { + return new Response(JSON.stringify({ result }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** A minimal in-memory NIP-86 relay: only supports a pubkey ban list. */ +function installMockRelay() { + const state = { banned: [{ pubkey: PUBKEY_A, reason: 'spam' }] }; + + const fetchMock = vi.fn(async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = typeof input === 'object' && 'method' in input ? input.method : (init?.method ?? 'GET'); + + if (method === 'GET' || url.endsWith('/') && !init?.body) { + return new Response( + JSON.stringify({ name: 'Test Relay', description: 'mock', software: 'mock', version: '1' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + const auth = new Headers(init?.headers).get('Authorization') ?? ''; + if (!auth.startsWith('Nostr ')) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }); + } + + const { method: rpc, params = [] } = JSON.parse(String(init?.body)); + switch (rpc) { + case 'supportedmethods': + return rpcResponse(['supportedmethods', 'banpubkey', 'unbanpubkey', 'listbannedpubkeys']); + case 'listbannedpubkeys': + return rpcResponse(state.banned); + case 'banpubkey': + state.banned = [...state.banned, { pubkey: String(params[0]), reason: params[1] }]; + return rpcResponse(true); + case 'unbanpubkey': + state.banned = state.banned.filter((entry) => entry.pubkey !== params[0]); + return rpcResponse(true); + default: + return new Response(JSON.stringify({ error: `unknown method: ${rpc}` }), { status: 200 }); + } + }); + + vi.stubGlobal('fetch', fetchMock); + return { fetchMock, state }; +} + +async function renderLoggedIn() { + const nsec = nip19.nsecEncode(generateSecretKey()); + const rendered = renderHook( + () => ({ + actions: useLoginActions(), + connection: useNip86Connection(), + }), + { wrapper: TestApp }, + ); + // NostrLoginProvider renders null while it reads logins from storage. + await waitFor(() => expect(rendered.result.current).not.toBeNull()); + act(() => rendered.result.current.actions.nsec(nsec)); + // useCurrentUser derives the signer in an effect-driven chain; wait until + // the connection hook reports authorization is possible. + await waitFor(() => expect(rendered.result.current.connection.canAuthorize).toBe(true)); + return rendered; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('useNip86Connection', () => { + it('requires sign-in before connecting', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useNip86Connection(), { wrapper: TestApp }); + // NostrLoginProvider renders null while it reads logins from storage. + await waitFor(() => expect(result.current).not.toBeNull()); + await act(async () => { + await result.current.connect('wss://relay.example.com'); + }); + + expect(result.current.session).toBeUndefined(); + expect(result.current.error?.code).toBe('not-logged-in'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects malformed relay URLs without touching the network', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { result } = await renderLoggedIn(); + await act(async () => { + await result.current.connection.connect('ftp://nope'); + }); + + expect(result.current.connection.error?.code).toBe('invalid-url'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('discovers capabilities, then serves capability-gated lists and mutations', async () => { + installMockRelay(); + const { result } = await renderLoggedIn(); + + await act(async () => { + await result.current.connection.connect('mock.relay.test'); + }); + + const session = result.current.connection.session; + expect(session).toBeTruthy(); + expect(session?.url).toBe('https://mock.relay.test/'); + expect(session?.info?.name).toBe('Test Relay'); + expect(session?.methods).toContain('listbannedpubkeys'); + expect(result.current.connection.error).toBeUndefined(); + + // The discovery itself is audited. + expect(result.current.connection.audit.some((entry) => entry.method === 'supportedmethods' && entry.status === 'ok')).toBe(true); + + // List hook reads through the same connection. + const list = renderHook( + () => ({ + list: useBannedPubkeys(result.current.connection.session, { onResult: result.current.connection.record }), + mutation: useNip86Mutation(result.current.connection.session, { onResult: result.current.connection.record }), + }), + { wrapper: TestApp }, + ); + + await waitFor(() => expect(list.result.current.list.isSuccess).toBe(true)); + expect(list.result.current.list.data).toHaveLength(1); + + // A mutation refreshes the list and writes an audit entry. + await act(async () => { + await list.result.current.mutation.mutateAsync({ + method: 'banpubkey', + params: ['f'.repeat(64), 'test ban'], + refresh: ['listbannedpubkeys'], + }); + }); + + await waitFor(() => expect(list.result.current.list.data).toHaveLength(2)); + expect( + result.current.connection.audit.some((entry) => entry.method === 'banpubkey' && entry.status === 'ok'), + ).toBe(true); + // The audit log must never contain an authorization header or key material. + for (const entry of result.current.connection.audit) { + expect(JSON.stringify(entry)).not.toContain('Nostr '); + } + + // Unadvertised methods are refused locally, without a request. + const callsBefore = (globalThis.fetch as ReturnType).mock.calls.length; + await act(async () => { + await expect( + list.result.current.mutation.mutateAsync({ method: 'blockip', params: ['203.0.113.1'] }), + ).rejects.toMatchObject({ code: 'unsupported' }); + }); + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(callsBefore); + }); + + it('surfaces a 401 as an authorization failure with a safe message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: 'nope' }), { status: 401 })), + ); + + const { result } = await renderLoggedIn(); + await act(async () => { + await result.current.connection.connect('wss://relay.example.com'); + }); + + expect(result.current.connection.session).toBeUndefined(); + expect(result.current.connection.error?.code).toBe('unauthorized'); + expect(result.current.connection.error?.message).not.toContain('Nostr '); + expect(result.current.connection.audit.some((entry) => entry.status === 'failed')).toBe(true); + }); +}); + +describe('usePolicyMode', () => { + it('derives the mode from advertised lists, never from the endpoint name', () => { + const { result, rerender } = renderHook(({ methods }) => usePolicyMode(methods), { + initialProps: { methods: ['listbannedpubkeys', 'listblockedips'] }, + }); + expect(result.current).toBe('blocklist'); + + rerender({ methods: ['listallowedpubkeys', 'listallowedkinds'] }); + expect(result.current).toBe('allowlist'); + + rerender({ methods: ['listbannedpubkeys', 'listallowedpubkeys'] }); + expect(result.current).toBe('unknown'); + + rerender({ methods: ['supportedmethods'] }); + expect(result.current).toBe('unknown'); + }); +});