1// The HTTP bridge: the same three verbs as the websocket, for scripts,2// serverless functions and agents that don't want a long-lived socket.3//4// POST /events body: one signed event -> {event_id, accepted, message}5// POST /query body: [filter, ...] -> [event, ...]6// POST /count body: [filter, ...] -> {count, hll?}7//8// All three take NIP-98 (Authorization: Nostr <base64 event>); the signer is9// treated exactly like a NIP-42-authenticated websocket client.10import { validate, type Event } from "./event.ts";11import { parseFilter, type Filter } from "./filter.ts";12import { hllOffset } from "./hll.ts";13import { verifyNIP98 } from "./auth.ts";14import { readGate } from "./gates.ts";15import type { Relay } from "./relay.ts";1617export async function bridge(relay: Relay, req: Request): Promise<Response> {18 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });19 const url = new URL(req.url);20 const body = await req.text();21 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, body);22 if (typeof auth === "string") return json({ error: auth }, 401);23 const conn = relay.virtualConn(url.host, auth.pubkey, req.headers.get("x-relay-ip") || "unknown");24 const t = Math.floor(Date.now() / 1000);25 // The bridge has no socket to meter, so the address bucket is its rate limit.26 const limited = relay.ipLimit(conn.ip, url.pathname === "/events" ? "events" : "reqs");27 if (limited) return json({ error: limited }, 429);2829 if (url.pathname === "/events") {30 let raw: unknown;31 try {32 raw = JSON.parse(body);33 } catch {34 return json({ error: "invalid: body is not JSON" }, 400);35 }36 const bad = validate(raw);37 if (bad) return json({ error: bad }, 400);38 const e = raw as Event;39 const r = await relay.acceptAny(e, conn);40 if (r.stored) relay.broadcast(e);41 return json({ event_id: e.id, accepted: r.ok, message: r.msg }, r.ok ? 200 : 400);42 }4344 let rawFilters: unknown;45 try {46 rawFilters = JSON.parse(body);47 } catch {48 return json({ error: "invalid: body is not JSON" }, 400);49 }50 if (!Array.isArray(rawFilters) || rawFilters.length === 0) return json({ error: "invalid: body must be a non-empty array of filters" }, 400);51 const filters: Filter[] = [];52 for (const rf of rawFilters) {53 const f = parseFilter(rf);54 if (typeof f === "string") return json({ error: "invalid: bad filter: " + f }, 400);55 filters.push(f);56 }57 const gate = readGate(relay, conn, filters);58 if (gate.reason) return json({ error: gate.reason }, gate.reason.startsWith("auth-required") || gate.reason.startsWith("restricted") ? 403 : 400);59 const who = { pubkeys: conn.authed };60 const p = relay.settings.policy;6162 if (url.pathname === "/count") {63 const result: Record<string, unknown> = {};64 if (filters.length === 1 && hllOffset(filters[0]) >= 0) {65 const r = relay.store.countHLL(filters[0], who, hllOffset(filters[0]), t);66 result.count = r.count;67 result.hll = r.hll;68 } else result.count = relay.store.count(filters, who, t);69 relay.tally();70 return json(result);71 }7273 // /query: OR of filters, deduplicated, newest first per filter order.74 const seen = new Set<string>();75 const out: string[] = [];76 for (const f of filters) {77 for (const raw of relay.store.query(f, who, p.maxLimit, t).rows) {78 const id = (JSON.parse(raw) as { id: string }).id;79 if (seen.has(id)) continue;80 seen.add(id);81 out.push(raw);82 }83 }84 relay.tally();85 return new Response("[" + out.join(",") + "]", { headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });86}