mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
Add integration tests, NIP.md, and apps.md docs for Relay Admin
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
e4026379bb
commit
e169b5e14e
29
NIP.md
Normal file
29
NIP.md
Normal file
@@ -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/`.
|
||||
18
docs/apps.md
18
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`
|
||||
|
||||
214
src/hooks/useNip86.test.tsx
Normal file
214
src/hooks/useNip86.test.tsx
Normal file
@@ -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<typeof fetch>(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<typeof fetch>();
|
||||
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<typeof fetch>();
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
it('surfaces a 401 as an authorization failure with a safe message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>(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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user