mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
beehive: verify direct relay membership over the relay HTTP contract
NIP-11 requirement discovery, host-keyed NIP-98 invite claim and
membership-gated /query evidence against the relay-signed kind 13534
roster, per the verified contract at block/buzz 051c3a2. Signer output
is re-verified before any request leaves; redirects, timeouts and
aborts are bounded; invite material never surfaces in errors.
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
398
beehive/src/direct-membership.ts
Normal file
398
beehive/src/direct-membership.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { verifyEvent, type Event } from 'nostr-tools/pure';
|
||||
import { digest, fields, object, text } from './protocol.ts';
|
||||
|
||||
/** Direct relay membership for a beehive host against an existing Buzz relay:
|
||||
* NIP-11 requirement discovery, host-signed invite claim (NIP-98 HTTP auth),
|
||||
* and direct-membership evidence verification against the relay's own signed
|
||||
* kind 13534 roster. Interfaces follow the relay contract at block/buzz
|
||||
* 051c3a270be9c73da9ab06700bcab7d5552fceaa (api/invites.rs, api/bridge.rs,
|
||||
* api/mod.rs, nip11.rs); source verification is not deployed proof, so every
|
||||
* check runs against the deployment origin supplied by the caller.
|
||||
*
|
||||
* Deliberate fences:
|
||||
* - The host signer is an injected async boundary; this module never reads a
|
||||
* keychain, never mints invite material, and never accepts or produces
|
||||
* owner NIP-OA/OAuth delegation.
|
||||
* - Host registration, agent placement and per-agent authorization are
|
||||
* separate concerns and absent here; the roster is the relay's existing
|
||||
* pubkey/role contract only.
|
||||
* - No redirect is ever followed: authorization and invite material must not
|
||||
* cross a redirect or origin change. Every request has a bounded timeout.
|
||||
*
|
||||
* Proof semantics for the integrator:
|
||||
* - `liveAdmission: 'verified'` is a fresh per-request row check: POST /query
|
||||
* runs the relay-membership gate on every request (query_events_authed),
|
||||
* so it is this module's equivalent of the no-OA NIP-42 AUTH live check
|
||||
* without duplicating the WebSocket runtime. It is point-in-time, not
|
||||
* continuous: an already-authenticated WebSocket elsewhere retains its
|
||||
* admission scope until it closes, and revocation is only observed by
|
||||
* re-verification. It says nothing about roles or agent authorization.
|
||||
* - `roster.status: 'observed'` is the relay-signed kind 13534 snapshot of
|
||||
* `relay_members` as of its `created_at`. Publication is best-effort after a
|
||||
* committed row change and may lag or fail; `already_member` claims do not
|
||||
* republish it. Old valid rosters are accepted (no freshness gate), and
|
||||
* roster absence never revokes committed or live evidence.
|
||||
* - A committed claim response is the relay's transactional readback of the
|
||||
* inserted row, not a publish ACK. None of these establish host
|
||||
* registration, normal enrollment completion or production admission.
|
||||
*/
|
||||
|
||||
/** Bounded HTTP transport boundary. Implementations MUST NOT follow redirects
|
||||
* (any 3xx is refused here as well) and must honor the per-request signal. */
|
||||
export type RelayHttpTransport = (request: {
|
||||
url: string;
|
||||
method: 'GET' | 'POST';
|
||||
headers: Record<string, string>;
|
||||
body: string | undefined;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
}) => Promise<{ status: number; bodyText: string }>;
|
||||
|
||||
/** Async host-key boundary. Implementations own key access and must produce a
|
||||
* fresh kind 27235 event per request (the relay rejects replayed event ids)
|
||||
* covering exactly the given url/method/payload hash. */
|
||||
export type HostAuthorizationSigner = {
|
||||
readonly hostPublicKey: string;
|
||||
signHttpAuthentication(request: { url: string; method: 'POST'; payloadSha256Hex: string }): Promise<string>;
|
||||
};
|
||||
|
||||
/** NIP-11 relay membership requirements. `membershipEnforced` is true only
|
||||
* when the deployment advertises NIP-43 together with a usable `self` key. */
|
||||
export type RelayMembershipRequirements = {
|
||||
readonly origin: string;
|
||||
readonly relaySelf: string | null;
|
||||
readonly membershipEnforced: boolean;
|
||||
readonly supportedNips: readonly number[];
|
||||
};
|
||||
|
||||
/** Committed invite-claim readback. The operator issued the invite; this
|
||||
* module never mints one and never logs or echoes the code. */
|
||||
export type InviteClaimOutcome = {
|
||||
readonly status: 'joined' | 'already_member';
|
||||
readonly communityId: string;
|
||||
readonly host: string;
|
||||
readonly role: string;
|
||||
readonly claimedAt: number;
|
||||
};
|
||||
|
||||
/** Relay-signed roster observation for the host key. */
|
||||
export type RosterObservation = {
|
||||
readonly status: 'observed' | 'absent' | 'unavailable' | 'not-checked';
|
||||
readonly role?: string;
|
||||
readonly createdAt?: number;
|
||||
/** Claim committed but the roster does not list the host: publication is
|
||||
* best-effort after the commit, so it may lag or have failed. */
|
||||
readonly lagging: boolean;
|
||||
};
|
||||
|
||||
/** Truthful observation, never a permanent approved boolean. Precedence:
|
||||
* denied > live-verified-member > roster-member > claim-committed > unknown. */
|
||||
export type DirectMembershipEvidence = {
|
||||
readonly relay: string;
|
||||
readonly origin: string;
|
||||
readonly hostPublicKey: string;
|
||||
readonly discovery: 'observed' | 'unavailable';
|
||||
readonly relaySelf: string | null;
|
||||
readonly membershipEnforced: boolean;
|
||||
readonly claim: InviteClaimOutcome | null;
|
||||
readonly liveAdmission: 'verified' | 'denied' | 'unavailable' | 'not-checked';
|
||||
readonly roster: RosterObservation;
|
||||
readonly status: 'live-verified-member' | 'roster-member' | 'claim-committed' | 'denied' | 'unknown';
|
||||
};
|
||||
|
||||
const NIP98_KIND = 27235;
|
||||
const NIP98_TOLERANCE_SECONDS = 60;
|
||||
const ROSTER_KIND = 13534;
|
||||
const MEMBERSHIP_NIP = 43;
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const MAX_BODY_BYTES = 262144;
|
||||
|
||||
/** Default platform-fetch adapter: redirects are refused, no credentials or
|
||||
* proxies are configured, and response bodies are size-bounded. It is the
|
||||
* only place this module touches the network. */
|
||||
export function fetchRelayTransport(fetcher: typeof globalThis.fetch = globalThis.fetch.bind(globalThis)): RelayHttpTransport {
|
||||
return async request => {
|
||||
const response = await fetcher(request.url, { method: request.method, headers: request.headers, body: request.body, redirect: 'error', signal: request.signal });
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (declared > MAX_BODY_BYTES) throw Error('Relay response exceeds bounded size');
|
||||
const bodyText = await response.text();
|
||||
if (bodyText.length > MAX_BODY_BYTES) throw Error('Relay response exceeds bounded size');
|
||||
return { status: response.status, bodyText };
|
||||
};
|
||||
}
|
||||
|
||||
/** Normal HTTP origin of a relay URL: wss→https, https→https; ws/http only
|
||||
* for 127.0.0.1 loopback fixtures, matching the package transport rules. */
|
||||
export function relayHttpOrigin(relay: string): URL {
|
||||
const url = new URL(relay);
|
||||
if (url.username || url.password || url.search || url.hash || (url.pathname !== '' && url.pathname !== '/')) throw Error('Relay origin URL expected');
|
||||
const secure = url.protocol === 'wss:' || url.protocol === 'https:';
|
||||
const loopback = (url.protocol === 'ws:' || url.protocol === 'http:') && url.hostname === '127.0.0.1';
|
||||
if (!secure && !loopback) throw Error('Relay requires wss/https (loopback ws/http fixtures only)');
|
||||
return new URL(`${secure ? 'https' : 'http'}://${url.host}/`);
|
||||
}
|
||||
|
||||
function boundedTimeout(value: number | undefined): number {
|
||||
const timeoutMs = value ?? DEFAULT_TIMEOUT_MS;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) throw Error('Invalid relay request timeout');
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
function hexPublicKey(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) throw Error(`Invalid ${label}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestScope(timeoutMs: number, external?: AbortSignal) {
|
||||
const controller = new AbortController();
|
||||
const abort = (message: string) => { if (!controller.signal.aborted) controller.abort(Error(message)); };
|
||||
const timer = setTimeout(() => abort('Relay HTTP request timeout'), timeoutMs);
|
||||
const cancel = () => abort('Relay HTTP request cancelled');
|
||||
if (external?.aborted) cancel();
|
||||
else external?.addEventListener('abort', cancel, { once: true });
|
||||
return { signal: controller.signal, done: () => { clearTimeout(timer); external?.removeEventListener('abort', cancel); } };
|
||||
}
|
||||
|
||||
type RelayRequest = { transport: RelayHttpTransport; url: string; method: 'GET' | 'POST'; headers: Record<string, string>; body: string | undefined; timeoutMs: number; signal?: AbortSignal };
|
||||
|
||||
/** One bounded request. The transport race is abandoned (not reported twice)
|
||||
* when the scope aborts; redirects are refused before any body is parsed. */
|
||||
async function relayFetch(request: RelayRequest): Promise<{ status: number; bodyText: string }> {
|
||||
const scope = requestScope(request.timeoutMs, request.signal);
|
||||
const attempt = request.transport({ url: request.url, method: request.method, headers: request.headers, body: request.body, timeoutMs: request.timeoutMs, signal: scope.signal });
|
||||
void attempt.catch(() => {});
|
||||
try {
|
||||
const response = await Promise.race([attempt, new Promise<never>((_, reject) => {
|
||||
// An abort that landed while the transport was being constructed (or a
|
||||
// pre-aborted caller signal) has already fired the signal's abort event;
|
||||
// a late listener would never fire and the race would hang, so reject
|
||||
// immediately instead.
|
||||
if (scope.signal.aborted) reject(scope.signal.reason);
|
||||
else scope.signal.addEventListener('abort', () => reject(scope.signal.reason), { once: true });
|
||||
})]);
|
||||
if (response.status >= 300 && response.status < 400) throw Error('Relay redirect refused; authorization and invite material not forwarded');
|
||||
if (!Number.isSafeInteger(response.status) || response.status < 200 || response.status > 599 || typeof response.bodyText !== 'string' || response.bodyText.length > MAX_BODY_BYTES) throw Error('Malformed relay transport response');
|
||||
return response;
|
||||
} finally { scope.done(); }
|
||||
}
|
||||
|
||||
/** Only module-owned, code-free reasons are surfaced; third-party transport
|
||||
* error text stays out of messages because it may echo request material. */
|
||||
function safeReason(error: unknown): string {
|
||||
return error instanceof Error && /^(Relay HTTP request timeout|Relay HTTP request cancelled|Relay redirect refused)/.test(error.message) ? error.message : 'transport failure';
|
||||
}
|
||||
|
||||
/** Bounded, control-character-free relay error field only; never a raw body.
|
||||
* An excerpt that echoes supplied request material (a hostile relay echoing
|
||||
* the invite code back) is coarsened rather than surfaced. */
|
||||
function relayErrorExcerpt(bodyText: string, secret?: string): string {
|
||||
try {
|
||||
const value = object(JSON.parse(bodyText));
|
||||
if (typeof value.error === 'string' && value.error.length > 0 && value.error.length <= 128 && !/[\x00-\x1f\x7f]/.test(value.error)) {
|
||||
return secret !== undefined && value.error.includes(secret) ? 'unspecified relay error' : value.error;
|
||||
}
|
||||
} catch { /* unparseable relay error bodies stay coarse */ }
|
||||
return 'unspecified relay error';
|
||||
}
|
||||
|
||||
/** Strictly typed Nostr event parse; rejects unknown/missing fields. */
|
||||
function parseEvent(value: unknown): Event {
|
||||
const e = object(value);
|
||||
fields(e, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
|
||||
if (typeof e.id !== 'string' || !/^[0-9a-f]{64}$/.test(e.id) || typeof e.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(e.pubkey)) throw Error('Invalid event identity');
|
||||
if (typeof e.sig !== 'string' || !/^[0-9a-f]{128}$/.test(e.sig)) throw Error('Invalid event signature');
|
||||
if (typeof e.content !== 'string' || e.content.length > 4096) throw Error('Invalid event content');
|
||||
if (!Number.isSafeInteger(e.created_at) || Number(e.created_at) < 0 || !Number.isSafeInteger(e.kind)) throw Error('Invalid event metadata');
|
||||
if (!Array.isArray(e.tags) || e.tags.length > 512) throw Error('Invalid event tags');
|
||||
for (const tag of e.tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 1 || tag.length > 16) throw Error('Invalid event tag');
|
||||
for (const item of tag) if (typeof item !== 'string' || item.length > 1024) throw Error('Invalid event tag value');
|
||||
}
|
||||
return { id: e.id, pubkey: e.pubkey, created_at: Number(e.created_at), kind: Number(e.kind), tags: e.tags as string[][], content: e.content, sig: e.sig };
|
||||
}
|
||||
|
||||
function tagValues(event: Event, name: string): string[] {
|
||||
return event.tags.filter(tag => tag[0] === name && tag.length >= 2).map(tag => tag[1]);
|
||||
}
|
||||
|
||||
/** Signer output is fully re-verified before any request leaves the module,
|
||||
* binding the signature to the exact host identity, URL, method and payload. */
|
||||
async function nip98Authorization(signer: HostAuthorizationSigner, url: string, body: string, now: number): Promise<string> {
|
||||
const hostPublicKey = hexPublicKey(signer.hostPublicKey, 'host public key');
|
||||
const payloadSha256Hex = digest(body).toString('hex');
|
||||
const serialized = await signer.signHttpAuthentication({ url, method: 'POST', payloadSha256Hex });
|
||||
let event: Event;
|
||||
try { event = parseEvent(JSON.parse(serialized)); } catch { throw Error('Signer returned a malformed NIP-98 event'); }
|
||||
if (event.kind !== NIP98_KIND) throw Error('Signer returned a non-NIP-98 event kind');
|
||||
if (event.pubkey !== hostPublicKey) throw Error('NIP-98 signer key does not match the specified host identity');
|
||||
const [u] = tagValues(event, 'u');
|
||||
if (tagValues(event, 'u').length !== 1 || u !== url) throw Error('NIP-98 event is not bound to this relay request URL');
|
||||
const [method] = tagValues(event, 'method');
|
||||
if (tagValues(event, 'method').length !== 1 || method?.toUpperCase() !== 'POST') throw Error('NIP-98 event does not cover this request method');
|
||||
const [payload] = tagValues(event, 'payload');
|
||||
if (tagValues(event, 'payload').length !== 1 || payload !== payloadSha256Hex) throw Error('NIP-98 event does not cover the request payload');
|
||||
if (Math.abs(event.created_at - now) > NIP98_TOLERANCE_SECONDS) throw Error('NIP-98 event timestamp is outside the relay acceptance window');
|
||||
if (!verifyEvent(event)) throw Error('NIP-98 event signature is invalid');
|
||||
return `Nostr ${Buffer.from(serialized, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function parseClaimResponse(value: unknown): { status: 'joined' | 'already_member'; communityId: string; host: string; role: string } {
|
||||
const v = object(value);
|
||||
fields(v, ['status', 'community_id', 'host', 'role']);
|
||||
if (v.status !== 'joined' && v.status !== 'already_member') throw Error('Invalid claim status');
|
||||
return { status: v.status, communityId: text(v.community_id, 128), host: text(v.host, 256), role: text(v.role, 64) };
|
||||
}
|
||||
|
||||
/** Discover relay membership requirements from the deployment's NIP-11
|
||||
* document (GET origin with `Accept: application/nostr+json`). NIP-43 is only
|
||||
* trustworthy together with a `self` signing key; anything else is reported
|
||||
* as not enforced and must never masquerade as verified membership. */
|
||||
export async function discoverRelayMembershipRequirements(input: { relay: string; transport?: RelayHttpTransport; timeoutMs?: number; signal?: AbortSignal }): Promise<RelayMembershipRequirements> {
|
||||
const origin = relayHttpOrigin(input.relay);
|
||||
const transport = input.transport ?? fetchRelayTransport();
|
||||
const response = await relayFetch({ transport, url: origin.toString(), method: 'GET', headers: { accept: 'application/nostr+json' }, body: undefined, timeoutMs: boundedTimeout(input.timeoutMs), signal: input.signal });
|
||||
if (response.status !== 200) throw Error(`Relay information unavailable (HTTP ${response.status})`);
|
||||
let document: Record<string, unknown>;
|
||||
try { document = object(JSON.parse(response.bodyText)); } catch { throw Error('Malformed relay information document'); }
|
||||
const relaySelf = document.self === undefined || document.self === null ? null : hexPublicKey(document.self, 'relay self key');
|
||||
const supportedNips: number[] = [];
|
||||
if (document.supported_nips !== undefined && document.supported_nips !== null) {
|
||||
if (!Array.isArray(document.supported_nips) || document.supported_nips.length > 128) throw Error('Malformed relay supported_nips');
|
||||
for (const nip of document.supported_nips) {
|
||||
if (!Number.isSafeInteger(nip) || Number(nip) < 0 || Number(nip) > 65535) throw Error('Malformed relay supported_nips');
|
||||
supportedNips.push(Number(nip));
|
||||
}
|
||||
}
|
||||
return { origin: origin.toString(), relaySelf, membershipEnforced: relaySelf !== null && supportedNips.includes(MEMBERSHIP_NIP), supportedNips };
|
||||
}
|
||||
|
||||
/** Claim an operator-issued invite with the host's own key (POST
|
||||
* /api/invites/claim, NIP-98 with a payload tag; deliberately outside the
|
||||
* membership gate). Only the joining host key signs; no owner key is used or
|
||||
* accepted. The invite code is request material: it is never logged, echoed,
|
||||
* or embedded in any error this module produces. A transport failure after
|
||||
* the request left is reported as an unknown commit outcome, never as a
|
||||
* clean failure. */
|
||||
export async function claimRelayMembershipInvite(input: {
|
||||
relay: string;
|
||||
signer: HostAuthorizationSigner;
|
||||
inviteCode: string;
|
||||
policyReceipt?: string;
|
||||
transport?: RelayHttpTransport;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
now?: number;
|
||||
}): Promise<InviteClaimOutcome> {
|
||||
const origin = relayHttpOrigin(input.relay);
|
||||
hexPublicKey(input.signer.hostPublicKey, 'host public key');
|
||||
const inviteCode = text(input.inviteCode, 1024);
|
||||
const policyReceipt = input.policyReceipt === undefined ? undefined : text(input.policyReceipt, 2048);
|
||||
const now = input.now ?? Math.floor(Date.now() / 1000);
|
||||
const transport = input.transport ?? fetchRelayTransport();
|
||||
const url = `${origin}api/invites/claim`;
|
||||
const body = JSON.stringify(policyReceipt === undefined ? { code: inviteCode } : { code: inviteCode, policy_receipt: policyReceipt });
|
||||
const authorization = await nip98Authorization(input.signer, url, body, now);
|
||||
let response: { status: number; bodyText: string };
|
||||
try {
|
||||
response = await relayFetch({ transport, url, method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization }, body, timeoutMs: boundedTimeout(input.timeoutMs), signal: input.signal });
|
||||
} catch (error) {
|
||||
throw Error(`Invite claim request failed before a relay outcome; commit status unknown (${safeReason(error)})`);
|
||||
}
|
||||
if (response.status !== 200) throw Error(`Invite claim rejected by the relay (HTTP ${response.status}: ${relayErrorExcerpt(response.bodyText, inviteCode)})`);
|
||||
let outcome: ReturnType<typeof parseClaimResponse>;
|
||||
try { outcome = parseClaimResponse(JSON.parse(response.bodyText)); } catch { throw Error('Malformed invite claim response'); }
|
||||
if (outcome.host !== origin.host) throw Error('Invite claim response is bound to a different relay host');
|
||||
return { status: outcome.status, communityId: outcome.communityId, host: outcome.host, role: outcome.role, claimedAt: now };
|
||||
}
|
||||
|
||||
/** Verified roster observation for the host key. Only events signed by the
|
||||
* NIP-11 `self` key count; unsigned lists and caller labels never do. */
|
||||
function rosterObservation(events: readonly unknown[], relaySelf: string, hostPublicKey: string, claimCommitted: boolean): RosterObservation {
|
||||
let newest: Event | undefined;
|
||||
for (const value of events) {
|
||||
let event: Event;
|
||||
try { event = parseEvent(value); } catch { continue; }
|
||||
if (event.kind !== ROSTER_KIND || event.pubkey !== relaySelf || !verifyEvent(event)) continue;
|
||||
if (!newest || event.created_at >= newest.created_at) newest = event;
|
||||
}
|
||||
if (!newest) return { status: 'unavailable', lagging: claimCommitted };
|
||||
const roles = new Set<string>();
|
||||
try {
|
||||
for (const tag of newest.tags) if (tag[0] === 'member' && tag.length >= 3 && tag[1] === hostPublicKey) roles.add(text(tag[2], 64));
|
||||
} catch { return { status: 'unavailable', lagging: claimCommitted }; }
|
||||
if (roles.size === 0) return { status: 'absent', lagging: claimCommitted };
|
||||
if (roles.size > 1) return { status: 'unavailable', lagging: claimCommitted };
|
||||
return { status: 'observed', role: [...roles][0], createdAt: newest.created_at, lagging: false };
|
||||
}
|
||||
|
||||
/** Gather direct-membership evidence for a host key against one relay:
|
||||
* NIP-11 discovery, then a host-signed membership-gated POST /query read
|
||||
* (the live per-request row check) whose response is also the roster source.
|
||||
* Signer misuse throws; transport failures downgrade truthfully to
|
||||
* 'unavailable'/'unknown' evidence instead of masquerading. */
|
||||
export async function verifyDirectMembershipEvidence(input: {
|
||||
relay: string;
|
||||
signer: HostAuthorizationSigner;
|
||||
claim?: InviteClaimOutcome;
|
||||
transport?: RelayHttpTransport;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
now?: number;
|
||||
}): Promise<DirectMembershipEvidence> {
|
||||
const origin = relayHttpOrigin(input.relay);
|
||||
const hostPublicKey = hexPublicKey(input.signer.hostPublicKey, 'host public key');
|
||||
const now = input.now ?? Math.floor(Date.now() / 1000);
|
||||
const transport = input.transport ?? fetchRelayTransport();
|
||||
const timeoutMs = boundedTimeout(input.timeoutMs);
|
||||
const claimInput = input.claim;
|
||||
let claim: InviteClaimOutcome | null = null;
|
||||
if (claimInput !== undefined) {
|
||||
// Re-validate the caller-supplied readback through the wire-shape parser
|
||||
// (same field/length rules as a fresh response) before treating it as
|
||||
// evidence, and preserve its local commit timestamp.
|
||||
if (!Number.isSafeInteger(claimInput.claimedAt) || claimInput.claimedAt < 0) throw Error('Invalid invite claim evidence');
|
||||
const parsed = parseClaimResponse({ status: claimInput.status, community_id: claimInput.communityId, host: claimInput.host, role: claimInput.role });
|
||||
claim = { status: parsed.status, communityId: parsed.communityId, host: parsed.host, role: parsed.role, claimedAt: claimInput.claimedAt };
|
||||
}
|
||||
if (claim !== null && claim.host !== origin.host) throw Error('Invite claim evidence is bound to a different relay host');
|
||||
let discovery: 'observed' | 'unavailable' = 'unavailable';
|
||||
let relaySelf: string | null = null;
|
||||
let membershipEnforced = false;
|
||||
try {
|
||||
const requirements = await discoverRelayMembershipRequirements({ relay: input.relay, transport, timeoutMs, signal: input.signal });
|
||||
if (requirements.origin !== origin.toString()) throw Error('Discovery origin mismatch');
|
||||
discovery = 'observed';
|
||||
relaySelf = requirements.relaySelf;
|
||||
membershipEnforced = requirements.membershipEnforced;
|
||||
} catch { /* unavailable discovery keeps later evidence unknown, never verified */ }
|
||||
let liveAdmission: DirectMembershipEvidence['liveAdmission'] = 'not-checked';
|
||||
let roster: RosterObservation = { status: 'not-checked', lagging: false };
|
||||
if (relaySelf !== null) {
|
||||
const url = `${origin}query`;
|
||||
const body = JSON.stringify([{ kinds: [ROSTER_KIND], authors: [relaySelf] }]);
|
||||
const authorization = await nip98Authorization(input.signer, url, body, now);
|
||||
try {
|
||||
const response = await relayFetch({ transport, url, method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization }, body, timeoutMs, signal: input.signal });
|
||||
if (response.status === 200) {
|
||||
const events: unknown = JSON.parse(response.bodyText);
|
||||
if (!Array.isArray(events) || events.length > 512) throw Error('Malformed relay query response');
|
||||
roster = rosterObservation(events, relaySelf, hostPublicKey, claim !== null);
|
||||
// Only an enforced relay's gate makes a 200 a live row check; an open
|
||||
// relay serves the same read to anyone and must not masquerade.
|
||||
if (membershipEnforced) liveAdmission = 'verified';
|
||||
} else if (membershipEnforced && response.status === 403 && relayErrorExcerpt(response.bodyText) === 'relay_membership_required') {
|
||||
liveAdmission = 'denied';
|
||||
roster = { status: 'unavailable', lagging: claim !== null };
|
||||
} else {
|
||||
liveAdmission = 'unavailable';
|
||||
roster = { status: 'unavailable', lagging: claim !== null };
|
||||
}
|
||||
} catch { liveAdmission = 'unavailable'; roster = { status: 'unavailable', lagging: claim !== null }; }
|
||||
}
|
||||
const status: DirectMembershipEvidence['status'] = liveAdmission === 'denied' ? 'denied'
|
||||
: liveAdmission === 'verified' ? 'live-verified-member'
|
||||
: roster.status === 'observed' ? 'roster-member'
|
||||
: claim !== null ? 'claim-committed'
|
||||
: 'unknown';
|
||||
return { relay: input.relay, origin: origin.toString(), hostPublicKey, discovery, relaySelf, membershipEnforced, claim, liveAdmission, roster, status };
|
||||
}
|
||||
365
beehive/test/direct-membership.test.ts
Normal file
365
beehive/test/direct-membership.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure';
|
||||
import {
|
||||
claimRelayMembershipInvite,
|
||||
discoverRelayMembershipRequirements,
|
||||
fetchRelayTransport,
|
||||
verifyDirectMembershipEvidence,
|
||||
type HostAuthorizationSigner,
|
||||
type RelayHttpTransport,
|
||||
} from '../src/direct-membership.ts';
|
||||
|
||||
const NOW = 1_700_000_000;
|
||||
const COMMUNITY_ID = '7e4e4d21-8d64-4c9a-9d3f-1b0f3d5a9c2e';
|
||||
|
||||
/** Narrow executable model of the relay's HTTP membership contract at block/buzz
|
||||
* 051c3a270be9c73da9ab06700bcab7d5552fceaa (api/invites.rs, api/bridge.rs,
|
||||
* api/mod.rs, store/relay_members.rs): NIP-11 discovery, NIP-98-signed POST
|
||||
* /api/invites/claim outside the membership gate, membership-gated POST
|
||||
* /query, and the kind 13534 roster ('-' marker plus ['member', pubkey, role]
|
||||
* tags) signed by the relay self key. NOT the deployed Rust relay: no tenant
|
||||
* DB, no HMAC invite oracle, no replay cache — fixtures grant fixture-policy
|
||||
* member rows only. The module under test is the client. */
|
||||
|
||||
function rosterEvent(relayKey: Uint8Array, members: readonly (readonly [string, string])[], createdAt: number): Event {
|
||||
return finalizeEvent({ kind: 13534, created_at: createdAt, tags: [['-'], ...members.map(([pubkey, role]) => ['member', pubkey, role])], content: '' }, relayKey);
|
||||
}
|
||||
|
||||
function hostSigner(secretKey: Uint8Array, options?: {
|
||||
key?: Uint8Array; // sign with a key other than the declared host identity
|
||||
now?: number; // created_at override
|
||||
url?: string; // misbound u tag
|
||||
payload?: string; // misbound payload hash
|
||||
kind?: number; // wrong event kind
|
||||
corrupt?: boolean; // well-formed event, invalid signature
|
||||
raw?: () => string; // arbitrary signer output
|
||||
}): HostAuthorizationSigner {
|
||||
return {
|
||||
hostPublicKey: getPublicKey(secretKey),
|
||||
async signHttpAuthentication(request) {
|
||||
if (options?.raw) return options.raw();
|
||||
const event = finalizeEvent({
|
||||
kind: options?.kind ?? 27235,
|
||||
created_at: options?.now ?? NOW,
|
||||
tags: [['u', options?.url ?? request.url], ['method', request.method], ['payload', options?.payload ?? request.payloadSha256Hex]],
|
||||
content: '',
|
||||
}, options?.key ?? secretKey);
|
||||
return JSON.stringify(options?.corrupt ? { ...event, sig: `${event.sig.slice(0, -1)}${event.sig.endsWith('0') ? '1' : '0'}` } : event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Server-side NIP-98 verification — the relay boundary the module must
|
||||
* satisfy: exact full URL, method, payload SHA-256 of the exact body, ±60s,
|
||||
* and a valid signature by the declaring key. */
|
||||
function verifyNip98ServerSide(authorization: string | undefined, url: string, body: string): string {
|
||||
assert.ok(typeof authorization === 'string' && authorization.startsWith('Nostr '), 'Nostr authorization header');
|
||||
const event = JSON.parse(Buffer.from(authorization.slice('Nostr '.length), 'base64').toString('utf8')) as Event;
|
||||
assert.ok(verifyEvent(event), 'NIP-98 signature');
|
||||
assert.equal(event.kind, 27235);
|
||||
assert.ok(Math.abs(event.created_at - NOW) <= 60, 'NIP-98 time window');
|
||||
const values = (name: string) => event.tags.filter(tag => tag[0] === name).map(tag => tag[1]);
|
||||
assert.deepEqual(values('u'), [url], 'exact full URL binding');
|
||||
assert.deepEqual(values('method'), ['POST'], 'method binding');
|
||||
assert.deepEqual(values('payload'), [createHash('sha256').update(body).digest('hex')], 'payload hash of the exact body');
|
||||
return event.pubkey;
|
||||
}
|
||||
|
||||
type ScriptedRelay = {
|
||||
readonly transport: RelayHttpTransport;
|
||||
readonly requests: { url: string; method: string; headers: Record<string, string>; body?: string }[];
|
||||
readonly members: Map<string, string>;
|
||||
};
|
||||
|
||||
/** Scripted in-memory relay (contract model above) with per-request capture. */
|
||||
function scriptedRelay(input: {
|
||||
relayKey: Uint8Array;
|
||||
enforced: boolean;
|
||||
self?: string | null;
|
||||
members?: readonly (readonly [string, string])[];
|
||||
roster?: readonly unknown[];
|
||||
communityId?: string;
|
||||
host?: string;
|
||||
claimOutcome?: (code: string, authorization: string | undefined) => { status: number; bodyText: string };
|
||||
queryOutcome?: (pubkey: string, authorization: string | undefined) => { status: number; bodyText: string };
|
||||
}): ScriptedRelay {
|
||||
const host = input.host ?? 'relay.example.com';
|
||||
const self = input.self === undefined ? getPublicKey(input.relayKey) : input.self;
|
||||
const members = new Map<string, string>(input.members ?? []);
|
||||
const requests: ScriptedRelay['requests'] = [];
|
||||
const transport: RelayHttpTransport = async request => {
|
||||
requests.push({ url: request.url, method: request.method, headers: { ...request.headers }, body: request.body });
|
||||
const url = new URL(request.url);
|
||||
if (request.method === 'GET' && url.pathname === '/') {
|
||||
const document: Record<string, unknown> = { name: 'fixture', supported_nips: input.enforced ? [1, 43] : [1] };
|
||||
if (self) document.self = self;
|
||||
return { status: 200, bodyText: JSON.stringify(document) };
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/api/invites/claim') {
|
||||
let code = '';
|
||||
try { code = String(JSON.parse(request.body ?? '{}').code); } catch { /* overrides still run */ }
|
||||
if (input.claimOutcome) return input.claimOutcome(code, request.headers.authorization);
|
||||
let pubkey: string;
|
||||
try { pubkey = verifyNip98ServerSide(request.headers.authorization, request.url, request.body ?? ''); }
|
||||
catch { return { status: 401, bodyText: JSON.stringify({ error: 'unauthorized' }) }; }
|
||||
const wasMember = members.has(pubkey);
|
||||
members.set(pubkey, 'member');
|
||||
return { status: 200, bodyText: JSON.stringify({ status: wasMember ? 'already_member' : 'joined', community_id: input.communityId ?? COMMUNITY_ID, host, role: 'member' }) };
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/query') {
|
||||
let pubkey: string;
|
||||
try { pubkey = verifyNip98ServerSide(request.headers.authorization, request.url, request.body ?? ''); }
|
||||
catch { return { status: 401, bodyText: JSON.stringify({ error: 'unauthorized' }) }; }
|
||||
if (input.queryOutcome) return input.queryOutcome(pubkey, request.headers.authorization);
|
||||
if (input.enforced && !members.has(pubkey)) return { status: 403, bodyText: JSON.stringify({ error: 'relay_membership_required' }) };
|
||||
return { status: 200, bodyText: JSON.stringify(input.roster ?? []) };
|
||||
}
|
||||
return { status: 404, bodyText: JSON.stringify({ error: 'not_found' }) };
|
||||
};
|
||||
return { transport, requests, members };
|
||||
}
|
||||
|
||||
test('positive wire flow: discovery, exact NIP-98 claim/query binding, verified roster readback, old roster accepted', async () => {
|
||||
const relayKey = generateSecretKey(), hostKey = generateSecretKey();
|
||||
const hostPubkey = getPublicKey(hostKey);
|
||||
const relaySelf = getPublicKey(relayKey);
|
||||
const fixture = scriptedRelay({ relayKey, enforced: true, roster: [rosterEvent(relayKey, [[hostPubkey, 'member']], NOW - 3600)] });
|
||||
const input = { relay: 'wss://relay.example.com', signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', transport: fixture.transport, now: NOW };
|
||||
|
||||
const claim = await claimRelayMembershipInvite(input);
|
||||
assert.deepEqual(claim, { status: 'joined', communityId: COMMUNITY_ID, host: 'relay.example.com', role: 'member', claimedAt: NOW });
|
||||
const secondClaim = await claimRelayMembershipInvite(input);
|
||||
assert.equal(secondClaim.status, 'already_member'); // idempotent committed-row readback
|
||||
const claimRequest = fixture.requests.at(-1)!;
|
||||
assert.equal(claimRequest.method, 'POST');
|
||||
assert.equal(claimRequest.url, 'https://relay.example.com/api/invites/claim');
|
||||
assert.deepEqual(JSON.parse(claimRequest.body!), { code: 'v2.fixture-code' });
|
||||
// Server-side NIP-98 verification inside the fixture proves the exact URL,
|
||||
// method and body-hash binding; a misbinding would have been a 401 above.
|
||||
|
||||
const requirements = await discoverRelayMembershipRequirements({ relay: 'wss://relay.example.com', transport: fixture.transport });
|
||||
assert.deepEqual(requirements, { origin: 'https://relay.example.com/', relaySelf, membershipEnforced: true, supportedNips: [1, 43] });
|
||||
const discoveryRequest = fixture.requests.at(-1)!;
|
||||
assert.equal(discoveryRequest.method, 'GET');
|
||||
assert.equal(discoveryRequest.url, 'https://relay.example.com/');
|
||||
assert.equal(discoveryRequest.headers.accept, 'application/nostr+json');
|
||||
|
||||
const evidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), claim, transport: fixture.transport, now: NOW });
|
||||
assert.equal(evidence.status, 'live-verified-member');
|
||||
assert.equal(evidence.liveAdmission, 'verified');
|
||||
assert.deepEqual(evidence.roster, { status: 'observed', role: 'member', createdAt: NOW - 3600, lagging: false });
|
||||
assert.deepEqual(evidence.claim, claim);
|
||||
const queryRequest = fixture.requests.at(-1)!;
|
||||
assert.equal(queryRequest.method, 'POST');
|
||||
assert.equal(queryRequest.url, 'https://relay.example.com/query');
|
||||
assert.deepEqual(JSON.parse(queryRequest.body!), [{ kinds: [13534], authors: [relaySelf] }]);
|
||||
|
||||
// An old (30-day) valid roster is accepted: no created_at freshness gate.
|
||||
const old = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], roster: [rosterEvent(relayKey, [[hostPubkey, 'member']], NOW - 30 * 86400)] });
|
||||
const oldEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: old.transport, now: NOW });
|
||||
assert.equal(oldEvidence.status, 'live-verified-member');
|
||||
assert.deepEqual(oldEvidence.roster, { status: 'observed', role: 'member', createdAt: NOW - 30 * 86400, lagging: false });
|
||||
|
||||
// Lagging publication: the member row exists (gate passes) but the roster
|
||||
// snapshot does not list the host yet — reported honestly, never as failure.
|
||||
const lagging = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], roster: [rosterEvent(relayKey, [], NOW)] });
|
||||
const laggingEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), claim, transport: lagging.transport, now: NOW });
|
||||
assert.equal(laggingEvidence.status, 'live-verified-member');
|
||||
assert.deepEqual(laggingEvidence.roster, { status: 'absent', lagging: true });
|
||||
});
|
||||
|
||||
test('misbound or forged signer output and forged rosters never verify or reach the wire', async () => {
|
||||
const relayKey = generateSecretKey(), hostKey = generateSecretKey(), otherKey = generateSecretKey();
|
||||
const fixture = scriptedRelay({ relayKey, enforced: true });
|
||||
const input = { relay: 'wss://relay.example.com', inviteCode: 'v2.fixture-code', transport: fixture.transport, now: NOW };
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { key: otherKey }) }), /signer key does not match/);
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { url: 'https://evil.example.com/api/invites/claim' }) }), /not bound to this relay request URL/);
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { payload: '0'.repeat(64) }) }), /does not cover the request payload/);
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { now: NOW - 61 }) }), /acceptance window/);
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { kind: 1 }) }), /non-NIP-98/);
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { corrupt: true }) }), /signature is invalid/);
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { raw: () => 'not json' }) }), /malformed NIP-98/);
|
||||
assert.equal(fixture.requests.length, 0); // nothing left the module
|
||||
|
||||
// A misbound signer cannot drive the gated query either: only the plain
|
||||
// NIP-11 GET ever ran, no POST left the module.
|
||||
await assert.rejects(verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey, { key: otherKey }), transport: fixture.transport, now: NOW }), /signer key does not match/);
|
||||
assert.deepEqual(fixture.requests.map(request => request.method), ['GET']);
|
||||
|
||||
// Forged rosters: a kind 13534 listing the host with an invented role is
|
||||
// only evidence when signed by the relay self key.
|
||||
const hostPubkey = getPublicKey(hostKey), attackerKey = generateSecretKey();
|
||||
const honest = rosterEvent(relayKey, [[hostPubkey, 'member']], NOW);
|
||||
const forged = [
|
||||
rosterEvent(attackerKey, [[hostPubkey, 'owner']], NOW), // wrong signer
|
||||
{ ...honest, tags: [['-'], ['member', hostPubkey, 'owner']] }, // relay-key identity, forged content and signature
|
||||
'not an event',
|
||||
];
|
||||
const forgedFixture = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], roster: forged });
|
||||
const evidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: forgedFixture.transport, now: NOW });
|
||||
assert.equal(evidence.roster.status, 'unavailable');
|
||||
assert.equal(evidence.roster.role, undefined);
|
||||
assert.equal(evidence.status, 'live-verified-member'); // via the gate, never via the forged list
|
||||
});
|
||||
|
||||
test('enforced denial, open relay and unknown relay are never reported live-verified', async () => {
|
||||
const relayKey = generateSecretKey(), hostKey = generateSecretKey();
|
||||
const hostPubkey = getPublicKey(hostKey);
|
||||
|
||||
const denied = scriptedRelay({ relayKey, enforced: true }); // host is not a member
|
||||
const deniedEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: denied.transport, now: NOW });
|
||||
assert.equal(deniedEvidence.liveAdmission, 'denied');
|
||||
assert.equal(deniedEvidence.status, 'denied');
|
||||
|
||||
// 401 auth failure and a foreign 403 are not membership denial.
|
||||
const unauthorized = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], queryOutcome: () => ({ status: 401, bodyText: JSON.stringify({ error: 'unauthorized' }) }) });
|
||||
assert.equal((await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: unauthorized.transport, now: NOW })).liveAdmission, 'unavailable');
|
||||
const foreign = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], queryOutcome: () => ({ status: 403, bodyText: JSON.stringify({ error: 'rate_limited' }) }) });
|
||||
assert.equal((await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: foreign.transport, now: NOW })).liveAdmission, 'unavailable');
|
||||
|
||||
// Open relay (self advertised, NIP-43 absent): the gate serves anyone, so a
|
||||
// 200 read is roster evidence at most, never live verification.
|
||||
const open = scriptedRelay({ relayKey, enforced: false, roster: [rosterEvent(relayKey, [[hostPubkey, 'member']], NOW)] });
|
||||
const openEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: open.transport, now: NOW });
|
||||
assert.equal(openEvidence.membershipEnforced, false);
|
||||
assert.equal(openEvidence.liveAdmission, 'not-checked');
|
||||
assert.equal(openEvidence.status, 'roster-member');
|
||||
const openCommitted = scriptedRelay({ relayKey, enforced: false, roster: [] });
|
||||
const committedEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: openCommitted.transport, now: NOW });
|
||||
assert.equal(committedEvidence.liveAdmission, 'not-checked');
|
||||
assert.equal(committedEvidence.status, 'unknown'); // no claim supplied, nothing gated
|
||||
|
||||
// No self key advertised: nothing to verify against, unknown.
|
||||
const noSelf = scriptedRelay({ relayKey, enforced: false, self: null });
|
||||
const noSelfEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: noSelf.transport, now: NOW });
|
||||
assert.equal(noSelfEvidence.discovery, 'observed');
|
||||
assert.equal(noSelfEvidence.relaySelf, null);
|
||||
assert.deepEqual(noSelfEvidence.roster, { status: 'not-checked', lagging: false });
|
||||
assert.equal(noSelfEvidence.status, 'unknown');
|
||||
assert.deepEqual(noSelf.requests.map(request => request.url), ['https://relay.example.com/']); // no gated query was attempted
|
||||
});
|
||||
|
||||
test('claim responses are validated and the invite code never appears in surfaced errors', async () => {
|
||||
const relayKey = generateSecretKey(), hostKey = generateSecretKey();
|
||||
const code = 'v2.super-secret-fixture-code';
|
||||
const input = { relay: 'wss://relay.example.com', signer: hostSigner(hostKey), inviteCode: code, now: NOW };
|
||||
|
||||
const invalid = scriptedRelay({ relayKey, enforced: true, claimOutcome: () => ({ status: 403, bodyText: JSON.stringify({ error: 'invite_invalid' }) }) });
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, transport: invalid.transport }), /invite_invalid/);
|
||||
|
||||
// A hostile relay echoing the invite code back in its error field is coarsened.
|
||||
const hostile = scriptedRelay({ relayKey, enforced: true, claimOutcome: () => ({ status: 403, bodyText: JSON.stringify({ error: `code ${code} is wrong` }) }) });
|
||||
let hostileError: Error | undefined;
|
||||
try { await claimRelayMembershipInvite({ ...input, transport: hostile.transport }); assert.fail('expected rejection'); }
|
||||
catch (error) { hostileError = error as Error; }
|
||||
assert.match(hostileError.message, /unspecified relay error/);
|
||||
assert.ok(!hostileError.message.includes(code), 'invite code must not surface in errors');
|
||||
|
||||
for (const bodyText of [
|
||||
'{"status":"nope","community_id":"c","host":"relay.example.com","role":"member"}', // invalid status
|
||||
'{"status":"joined","community_id":"c","role":"member"}', // missing host
|
||||
'{"status":"joined","community_id":"c","host":"evil.example.com","role":"member"}', // different relay host
|
||||
'not json',
|
||||
]) {
|
||||
const malformed = scriptedRelay({ relayKey, enforced: true, claimOutcome: () => ({ status: 200, bodyText }) });
|
||||
let error: Error | undefined;
|
||||
try { await claimRelayMembershipInvite({ ...input, transport: malformed.transport }); assert.fail('expected rejection'); }
|
||||
catch (caught) { error = caught as Error; }
|
||||
assert.ok(!error.message.includes(code), 'invite code must not surface in errors');
|
||||
}
|
||||
});
|
||||
|
||||
test('requests are bounded: timeout, external abort and redirects are refused truthfully', async () => {
|
||||
const relayKey = generateSecretKey(), hostKey = generateSecretKey();
|
||||
const input = { relay: 'wss://relay.example.com', signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', now: NOW };
|
||||
const hang: RelayHttpTransport = async () => await new Promise(() => {}); // ignores the signal: the module must bound it itself
|
||||
|
||||
let timeout: Error | undefined;
|
||||
try { await claimRelayMembershipInvite({ ...input, transport: hang, timeoutMs: 40 }); assert.fail('expected rejection'); }
|
||||
catch (caught) { timeout = caught as Error; }
|
||||
assert.match(timeout.message, /commit status unknown \(Relay HTTP request timeout\)/);
|
||||
assert.ok(!timeout.message.includes(input.inviteCode));
|
||||
|
||||
// An abort that already fired before the request leaves must reject promptly,
|
||||
// not hang on a transport that ignores the signal.
|
||||
const preAborted = new AbortController();
|
||||
preAborted.abort();
|
||||
let abortedEarly: Error | undefined;
|
||||
try { await claimRelayMembershipInvite({ ...input, transport: hang, signal: preAborted.signal, timeoutMs: 5000 }); assert.fail('expected rejection'); }
|
||||
catch (caught) { abortedEarly = caught as Error; }
|
||||
assert.match(abortedEarly.message, /Relay HTTP request cancelled/);
|
||||
assert.ok(!abortedEarly.message.includes(input.inviteCode));
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 10).unref?.();
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, transport: hang, signal: controller.signal, timeoutMs: 5000 }), /Relay HTTP request cancelled/);
|
||||
|
||||
const redirect: RelayHttpTransport = async () => ({ status: 302, bodyText: '' });
|
||||
await assert.rejects(claimRelayMembershipInvite({ ...input, transport: redirect }), /redirect refused/); // authorization is never forwarded across a redirect
|
||||
await assert.rejects(discoverRelayMembershipRequirements({ relay: 'wss://relay.example.com', transport: redirect }), /redirect refused/);
|
||||
});
|
||||
|
||||
test('real loopback HTTP journey through fetchRelayTransport with server-side NIP-98 verification', async () => {
|
||||
const relayKey = generateSecretKey(), hostKey = generateSecretKey();
|
||||
const hostPubkey = getPublicKey(hostKey);
|
||||
const relaySelf = getPublicKey(relayKey);
|
||||
const members = new Map<string, string>();
|
||||
const roster = rosterEvent(relayKey, [[hostPubkey, 'member']], NOW - 60);
|
||||
const verified: string[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
const hostHeader = request.headers.host ?? '';
|
||||
const send = (status: number, bodyText: string, contentType = 'application/json') => { response.writeHead(status, { 'content-type': contentType }); response.end(bodyText); };
|
||||
if (request.method === 'GET' && request.url === '/') { send(200, JSON.stringify({ name: 'loopback-fixture', supported_nips: [1, 43], self: relaySelf }), 'application/nostr+json'); return; }
|
||||
if (request.method === 'GET' && request.url === '/redirect') { response.writeHead(302, { location: '/' }); response.end(); return; }
|
||||
if (request.method === 'GET' && request.url === '/oversized') { response.writeHead(200, { 'content-length': '300000' }); response.end('x'.repeat(300000)); return; }
|
||||
try {
|
||||
const pubkey = verifyNip98ServerSide(request.headers.authorization, `http://${hostHeader}${request.url}`, body);
|
||||
verified.push(pubkey);
|
||||
if (request.url === '/api/invites/claim') {
|
||||
const wasMember = members.has(pubkey);
|
||||
members.set(pubkey, 'member');
|
||||
send(200, JSON.stringify({ status: wasMember ? 'already_member' : 'joined', community_id: COMMUNITY_ID, host: hostHeader, role: 'member' }));
|
||||
return;
|
||||
}
|
||||
if (request.url === '/query') {
|
||||
if (!members.has(pubkey)) { send(403, JSON.stringify({ error: 'relay_membership_required' })); return; }
|
||||
send(200, JSON.stringify([roster]));
|
||||
return;
|
||||
}
|
||||
send(404, JSON.stringify({ error: 'not_found' }));
|
||||
} catch (error) {
|
||||
send(401, JSON.stringify({ error: `unauthorized: ${(error as Error).message}` }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const relay = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const claim = await claimRelayMembershipInvite({ relay, signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', now: NOW });
|
||||
assert.equal(claim.status, 'joined');
|
||||
assert.equal(claim.host, `127.0.0.1:${address.port}`);
|
||||
assert.equal((await claimRelayMembershipInvite({ relay, signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', now: NOW })).status, 'already_member');
|
||||
const evidence = await verifyDirectMembershipEvidence({ relay, signer: hostSigner(hostKey), claim, now: NOW }); // default fetchRelayTransport
|
||||
assert.equal(evidence.status, 'live-verified-member');
|
||||
assert.equal(evidence.liveAdmission, 'verified');
|
||||
assert.deepEqual(evidence.roster, { status: 'observed', role: 'member', createdAt: roster.created_at, lagging: false });
|
||||
assert.deepEqual(verified, [hostPubkey, hostPubkey, hostPubkey]); // claim + both queries verified server-side
|
||||
const transport = fetchRelayTransport();
|
||||
await assert.rejects(transport({ url: `${relay}/redirect`, method: 'GET', headers: {}, body: undefined, timeoutMs: 2000, signal: new AbortController().signal }));
|
||||
await assert.rejects(transport({ url: `${relay}/oversized`, method: 'GET', headers: {}, body: undefined, timeoutMs: 2000, signal: new AbortController().signal }), /bounded size/);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user