clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / src / routes.ts
routes.ts 8.3K
1// The HTTP doors of a relay, in the order they are tried. A door marked2// gated writes or reads events or files, so a blocked address is refused at3// it; the page, NIP-11 and management stay reachable, so an owner who4// blocked their own address can undo it. The websocket upgrade is the one5// door relay.ts answers itself, before this table.6import { grasp, isGitPath } from "./grasp.ts";7import type { Relay } from "./relay.ts";8import { featureOn, type Feature } from "./settings.ts";9import { now } from "./event.ts";10import { manage } from "./manage.ts";11import { bridge } from "./bridge.ts";12import { nip11 } from "./nip11.ts";13import { nip05Document } from "./nip05.ts";14import { isWebAddressRequest, webAddress } from "./nipad.ts";15import { verifyNIP98, whoAsks } from "./auth.ts";16import { checkInvite, claimInviteRequest, invitePage, termsPage } from "./invites.ts";17import { dumpDownload } from "./dumps.ts";18import { backupDownload, restoreBackupRequest } from "./backups.ts";19import { serveView } from "./views.ts";20import { importUpload } from "./imports.ts";21import { blossom, isBlobPath } from "./blossom.ts";22import { nip96 } from "./nip96.ts";23import { fuelInvoice } from "./fuel.ts";24import { card } from "./card.ts";25import { connectDoor } from "./connections.ts";26import { isPagePath, pages } from "./pages.ts";27import { dashboard } from "./dashboard.ts";28import { SIGNER_JS } from "./gen/signer.ts";29import { FAVICON_SVG } from "./ui.ts";3031export interface Route {32 // when says whether this door answers the request.33 when: (url: URL, req: Request) => boolean;34 // A door that writes or reads events or files: refused to a blocked address.35 gated?: true;36 // The feature the door belongs to; switched off, the door is not there.37 feature?: Feature;38 answer: (relay: Relay, req: Request, url: URL) => Response | Promise<Response>;39}4041const CORS = { "access-control-allow-origin": "*" };42const json = (body: unknown, status = 200) => Response.json(body, { status, headers: CORS });43const html = (body: string) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });44const is = (...paths: string[]) => (url: URL) => paths.includes(url.pathname);45const under = (prefix: string) => (url: URL) => url.pathname.startsWith(prefix);46const get = (when: (url: URL) => boolean) => (url: URL, req: Request) => req.method === "GET" && when(url);47const post = (when: (url: URL) => boolean) => (url: URL, req: Request) => req.method === "POST" && when(url);48export const isManagementRequest = (req: Request) => req.method === "POST" && (req.headers.get("content-type")?.includes("application/nostr+json+rpc") ?? false);4950export const ROUTES: Route[] = [51 { when: isGitPath, gated: true, answer: grasp },52 // Explicit path discovery wins over content negotiation, independently53 // of member names. The handler applies each target's visibility policy.54 { when: isWebAddressRequest, gated: true, answer: webAddress },55 // NIP-11, after explicit discovery, by the accept header.56 {57 when: (_, req) => req.headers.get("accept")?.includes("application/nostr+json") ?? false,58 answer: (relay, _, url) => Response.json(nip11(relay, url.host), { headers: { "content-type": "application/nostr+json", ...CORS } }),59 },60 // NIP-86 management, by the content type.61 { when: (_, req) => isManagementRequest(req), answer: manage },62 // The HTTP bridge (bridge.ts).63 { when: post(is("/events", "/query", "/count")), gated: true, answer: bridge },64 // NIP-05 names, under the read rule for whoever asks.65 {66 when: is("/.well-known/nostr.json"),67 feature: "names",68 answer: (relay, req, url) => {69 const who = whoAsks(req, "", null);70 if (typeof who === "string") return json({ error: who }, 401);71 return json(nip05Document(relay.settings, url.searchParams.get("name"), relay.relayURL(url.host), who.pubkeys));72 },73 },74 // The directory, for the console: names and roles when the asker may list them.75 {76 when: get(is("/people")),77 answer: (relay, req, url) => {78 const p = relay.settings.policy;79 const who = whoAsks(req, "", null);80 if (typeof who === "string") return json({ error: who }, 401);81 const listed = p.owner !== "" && relay.settings.mayList(who.pubkeys) === "";82 const people = listed ? relay.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];83 return json({ public: p.directoryPublic, self: relay.identity.pubkey, host: url.host, people });84 },85 },86 // Invites (invites.ts): the terms, the invite page, the join policy, the claim.87 {88 when: get(is("/terms")),89 answer: (relay, _, url) => {90 const terms = relay.settings.policy.joinTerms;91 if (!terms) return new Response("this relay has no terms", { status: 404 });92 return html(termsPage(relay.settings.policy.name || relay.slug, url.host, terms));93 },94 },95 {96 when: get(under("/invite/")),97 answer: (relay, _, url) => {98 const code = url.pathname.slice(8);99 const status = relay.settings.policy.owner === "" ? "invite_invalid" : checkInvite(relay.sql, code, now());100 return html(invitePage(relay.settings.policy.name || relay.slug, url.host, code, status, relay.settings.policy.joinTerms));101 },102 },103 { when: get(is("/api/join-policy")), answer: (relay) => json({ terms: relay.settings.policy.joinTerms }) },104 { when: post(is("/api/invites/claim")), gated: true, answer: claimInviteRequest },105 // Dumps, views, imports (dumps.ts, views.ts, imports.ts).106 { when: get(under("/dumps/")), answer: dumpDownload },107 { when: get(under("/backups/")), answer: (relay, req, url) => backupDownload(relay, req, url.pathname.slice("/backups/".length)) },108 { when: (url, req) => req.method === "POST" && (url.pathname === "/backups/restore" || url.pathname === "/backups/preview"), answer: restoreBackupRequest },109 { when: get(under("/view/")), answer: (relay, req) => serveView(relay, req, verifyNIP98) },110 { when: (url, req) => req.method === "PUT" && url.pathname === "/import", answer: importUpload },111 // Files: Blossom on its paths (blossom.ts), NIP-96 on its own (nip96.ts).112 { when: (url) => is("/upload", "/mirror", "/report")(url) || under("/list/")(url) || isBlobPath(url.pathname), gated: true, feature: "files", answer: blossom },113 { when: (url) => is("/.well-known/nostr/nip96.json", "/nip96")(url) || under("/nip96/")(url), feature: "files", answer: nip96 },114 // Fuel: meters and prices for anyone who might top up (who paid is the115 // owner's, in the stats method), and the invoice door (fuel.ts).116 { when: get(is("/fuel")), answer: (relay) => json(relay.fuelStatus()) },117 { when: post(is("/fuel/invoice")), gated: true, answer: fuelInvoice },118 // The card (card.ts).119 { when: is("/card.json", "/card.nostr", "/card.svg", "/qr.svg"), answer: card },120 // The Connect fold's app shortcuts, as the asker may see them (connections.ts).121 { when: get(is("/connect.json")), answer: connectDoor },122 // CORS preflight for everything above.123 { when: (_, req) => req.method === "OPTIONS", answer: () => new Response(null, { headers: { ...CORS, "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } }) },124 // Notes and articles as pages, the feed (pages.ts).125 { when: (url, req) => req.method === "GET" && isPagePath(url.pathname), feature: "pages", answer: pages },126 // The relay's own page (dashboard.ts) and what it loads.127 { when: is("/"), answer: () => html(dashboard()) },128 { when: is("/signer.js"), answer: () => new Response(SIGNER_JS, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=604800, immutable" } }) },129 { when: is("/favicon.svg", "/favicon.ico"), answer: () => new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } }) },130];131132// isGated says whether the request is at a door a blocked address may not use.133export function isGated(url: URL, req: Request): boolean {134 return ROUTES.some((r) => r.gated && r.when(url, req));135}136137// route answers the request at the first door that takes it, or 404.138export function route(relay: Relay, req: Request, url: URL): Response | Promise<Response> {139 for (const r of ROUTES) {140 if (r.feature && !featureOn(relay.settings.policy, r.feature)) continue;141 if (r.when(url, req)) return r.answer(relay, req, url);142 }143 return new Response("not found", { status: 404 });144}