clone

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

dependabot/github_actions/actions-9b61906d8b
bindws / src / index.ts
index.ts 6.1K
1// Worker entry: maps <name>.<DOMAIN> to that relay's Durable Object, serves2// the apex, and forwards everything else untouched (websocket upgrades3// included) so the object can answer.4import { Relay, type Env } from "./relay.ts";5import { landing } from "./landing.ts";6import { FAVICON_SVG } from "./ui.ts";7import { verifyNIP98 } from "./auth.ts";8import { leaseNames, leaseDays, validName } from "./names.ts";9import { now } from "./event.ts";10import { clientIP, customHost, hostnameKnown, leaseAllowed, siteHost } from "./edge.ts";11import { customTarget } from "./domains.ts";12import schema from "../relay-config.schema.json";13import connectionSchema from "../connection-template.schema.json";1415// The entry module exports the handler and the object only: workerd16// refuses any other kind of export here.17export { Relay };18export { NAME_RE, RESERVED, validName } from "./names.ts";1920// lease hands out a temporary relay at a memorable name: open to everyone21// for a while, wiped after unless claimed. Anyone may ask, no key needed;22// a NIP-98 signature reserves the claim for that key. For scripts, agents23// and the button on the front page alike.24async function lease(req: Request, env: Env, apex: URL): Promise<Response> {25 const cors = { "access-control-allow-origin": "*", "content-type": "application/json" };26 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: cors });27 // Each lease is an object with storage, so the door is narrow: a few per28 // address per minute, and a ceiling for everyone together.29 if (!(await leaseAllowed(env, clientIP(req, env)))) return json({ error: "rate-limited: too many leases, try again in a minute" }, 429);30 const body = await req.text();31 let holder = "";32 const authz = req.headers.get("authorization");33 if (authz) {34 const auth = verifyNIP98(authz, req.url, req.method, body);35 if (typeof auth === "string") return json({ error: auth }, 401);36 holder = auth.pubkey;37 }38 const days = leaseDays(env);39 const until = now() + days * 86400;40 // wrangler dev serves the apex at <domain>.localhost; relays are <name>.localhost.41 const dev = apex.hostname !== env.DOMAIN.toLowerCase() && apex.hostname !== "www." + env.DOMAIN.toLowerCase();42 const suffix = dev ? ".localhost" + (apex.port ? ":" + apex.port : "") : "." + env.DOMAIN;43 for (const name of leaseNames()) {44 const host = name + suffix;45 const err = await env.RELAY.getByName(name).lease(name, host, until, holder);46 if (err) continue;47 return json({48 name,49 url: (dev ? "ws://" : "wss://") + host,50 console: (dev ? "http://" : "https://") + host + "/",51 expires_at: until,52 days,53 holder: holder || undefined,54 claim: `Open the console or send a NIP-86 "claim" signed with NIP-98 before it expires. Once claimed it is yours for good, events and files included; the console then offers to reset the rules to the defaults.`,55 }, 201);56 }57 return json({ error: "error: no free name found, try again" }, 503);58}5960export default {61 async fetch(req: Request, env: Env): Promise<Response> {62 const url = new URL(req.url);63 const host = url.hostname.toLowerCase();64 const domain = env.DOMAIN.toLowerCase();65 // A proxy issuing certificates on demand asks whether a hostname is66 // ours before it asks the certificate authority (docs/16). Any host,67 // no body: 200 or 404.68 if (url.pathname === "/.well-known/bindws/hostname" && req.method === "GET") {69 return new Response(null, { status: (await hostnameKnown(env, url.searchParams.get("domain") ?? "")) ? 200 : 404 });70 }71 let name: string | null;72 let site = "";73 if (host === domain || host === "www." + domain || host === domain + ".localhost") name = null; // apex (<domain>.localhost in wrangler dev)74 else if (host.endsWith("." + domain)) name = host.slice(0, -(domain.length + 1));75 else if (host.endsWith(".localhost")) name = host.slice(0, -".localhost".length); // wrangler dev: <name>.localhost76 else {77 const target = customTarget(await customHost(env, host));78 name = target?.name ?? env.DEV_RELAY;79 site = target?.site ?? "";80 }81 if (name !== null && name.length > 32) {82 site = name;83 name = await siteHost(env, site);84 if (!name) return new Response("Not found", { status: 404 });85 }86 if (name === null) {87 if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") return new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } });88 // The configuration file's schema, for editors: relay-config.schema.json in the repository.89 if (url.pathname === "/relay-config.schema.json") return Response.json(schema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } });90 // The connection template's schema: connection-template.schema.json in the repository.91 if (url.pathname === "/connection-template.schema.json") return Response.json(connectionSchema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } });92 if (url.pathname === "/lease" && req.method === "POST") return lease(req, env, url);93 if (url.pathname === "/lease" && req.method === "OPTIONS") {94 return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "POST, OPTIONS" } });95 }96 return landing(req, env);97 }98 if (!validName(name) || name.includes(".")) {99 return Response.redirect(`https://${domain}/?bad=${encodeURIComponent(name)}`, 302);100 }101 const stub = env.RELAY.getByName(name);102 const headers = new Headers(req.headers);103 headers.delete("x-relay-site");104 if (site) headers.set("x-relay-site", site);105 headers.set("x-relay-name", name);106 // The client's address, as the edge saw it. Set here, never read from107 // the client's own headers, so nothing a caller sends can spoof it.108 headers.set("x-relay-ip", clientIP(req, env));109 return stub.fetch(new Request(req, { headers }));110 },111} satisfies ExportedHandler<Env>;