clone

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

main
bindws / src / landing.ts
landing.ts 16.4K
1// The apex page: what bind.ws is, drawn as the system it is, with the2// datasheet under it. Every number comes from the same config the relay runs3// on, so the page cannot drift from the service.4import { MAX_MESSAGE, type Env } from "./relay.ts";5import { VERSION } from "./nip11.ts";6import { leaseDays } from "./names.ts";7import { fuelConfig } from "./fuel.ts";8import { DEFAULT_POLICY } from "./settings.ts";9import { page, escapeHTML } from "./ui.ts";1011const REPO = "https://github.com/FelineStateMachine/bindws";1213const CSS = `14main { max-width: 68rem; padding-top: 2rem; }15.head { text-align: center; margin: 2rem 0 2.5rem; }16.head h1 { font-size: clamp(3rem, 8vw, 4.4rem); }17.head p { margin: 1rem auto 0; color: var(--ink-2); font-size: 1.1rem; }18.head .try { display: flex; gap: .8rem; align-items: center; justify-content: center; flex-wrap: wrap; margin-top: 1.4rem; font-size: 15px; }19.head .try .btn { box-shadow: 3px 3px 0 var(--ink); }20.map { display: grid; grid-template-columns: 1.25fr 1fr; gap: 2.5rem; align-items: start; }21.map > * { min-width: 0; }22.map .box { background: var(--paper); border: 2px solid var(--ink); border-radius: 12px; padding: 1rem; box-shadow: 4px 4px 0 var(--ink); }23svg { width: 100%; height: auto; display: block; user-select: none; -webkit-user-select: none; }24svg text { font: 12px var(--mono); fill: var(--ink); } svg text.k { fill: var(--forest); font-weight: 500; } svg text.d { fill: var(--ink-3); font-size: 11px; }25svg .n { fill: var(--paper); stroke: var(--ink); stroke-width: 2; } svg .sh { fill: var(--ink); } svg .e { stroke: var(--ink); stroke-width: 1.6; fill: none; }26.notes h3 { margin: 0 0 .3rem; font: 500 12px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }27.notes p { margin: 0 0 1.2rem; color: var(--ink-2); max-width: 30rem; font-size: 15px; } .notes p b { color: var(--ink); }28.sheet h2 { font: 500 12px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; margin: 3rem 0 .6rem; display: flex; align-items: center; gap: .6rem; }29.sheet h2::after { content: ""; flex: 1; height: 1.5px; background: var(--ink); margin: 0; width: auto; border-radius: 0; transform: none; }30table { width: 100%; border-collapse: collapse; font-size: 14px; }31th { text-align: left; font: 500 11px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); padding: .2rem .6rem .4rem 0; }32td { padding: .35rem .6rem .35rem 0; border-top: 1px solid var(--line); vertical-align: top; color: var(--ink-2); }33td:first-child { color: var(--ink); font-family: var(--mono); font-size: 13px; white-space: nowrap; } td.r, th.r { text-align: right; font-family: var(--mono); white-space: nowrap; color: var(--ink); }34.desc { color: var(--ink-2); max-width: 40rem; margin: .6rem 0 0; }35.foot { margin-top: 4rem; padding-top: 1rem; border-top: 1.5px solid var(--ink); font: 13px var(--mono); display: flex; justify-content: space-between; } .foot a { text-decoration: none; color: var(--ink-2); } .foot a:hover { color: var(--ink); }36::selection { background: var(--sun); color: var(--ink); }37@media (max-width: 60rem) { .map { grid-template-columns: 1fr; } }38@media (max-width: 44rem) {39 main { padding: 1.2rem 1rem 3rem; }40 table, tbody, tr, td { display: block; width: 100%; } thead { display: none; }41 tr { border-top: 1.5px solid var(--ink); padding: .6rem 0 .5rem; } tr:first-child { border-top: 0; }42 td { border: 0; padding: .12rem 0; display: grid; grid-template-columns: 6.5rem 1fr; gap: .6rem; align-items: baseline; text-align: left; white-space: normal; }43 td::before { content: attr(data-th); font: 500 10px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }44 td:first-child { display: block; font-size: 14px; color: var(--ink); padding-bottom: .3rem; white-space: normal; } td:first-child::before { content: none; }45 td.r { text-align: left; } .sheet h2 { margin-top: 2.2rem; }46}47`;4849const MAP = `<svg viewBox="0 0 640 470" role="img" aria-label="how a request reaches a relay on bind.ws">50<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#1c1b18"/></marker></defs>51<rect class="sh" x="24" y="24" width="110" height="34" rx="10"/><rect class="n" x="20" y="20" width="110" height="34" rx="10"/><text class="" x="75.0" y="41.0" text-anchor="middle">damus</text><rect class="sh" x="154" y="24" width="110" height="34" rx="10"/><rect class="n" x="150" y="20" width="110" height="34" rx="10"/><text class="" x="205.0" y="41.0" text-anchor="middle">amethyst</text><rect class="sh" x="284" y="24" width="110" height="34" rx="10"/><rect class="n" x="280" y="20" width="110" height="34" rx="10"/><text class="" x="335.0" y="41.0" text-anchor="middle">nostrudel</text><rect class="sh" x="414" y="24" width="110" height="34" rx="10"/><rect class="n" x="410" y="20" width="110" height="34" rx="10"/><text class="" x="465.0" y="41.0" text-anchor="middle">a script</text>52<path class="e" d="M75 54v26M205 54v26M335 54v26M465 54v26"/><path class="e" d="M75 80H465"/><path class="e" d="M270 80v34" marker-end="url(#ar)"/>53<text class="d" x="282" y="104">wss, https</text>54<text class="k" x="270" y="132" text-anchor="middle">wss://kitchen.bind.ws</text>55<path class="e" d="M270 138v22" marker-end="url(#ar)"/>56<rect class="sh" x="204" y="164" width="140" height="34" rx="10"/><rect class="n" x="200" y="160" width="140" height="34" rx="10"/><text class="k" x="270.0" y="181.0" text-anchor="middle">worker</text><text class="d" x="350" y="181">host -&gt; object</text>57<path class="e" d="M270 194v26" marker-end="url(#ar)"/>58<rect class="sh" x="114" y="224" width="320" height="136" rx="14"/><rect class="n" x="110" y="220" width="320" height="136" rx="14"/>59<text class="k" x="126" y="244">durable object "kitchen"</text>60<text x="126" y="270">sqlite events, tags, search, rules</text>61<text x="126" y="290">sockets live subscriptions</text>62<text x="126" y="310">alarm sweep, retention, fuel</text>63<text x="126" y="338" class="k">hibernates when idle</text>64<path class="e" d="M270 356v26" marker-end="url(#ar)"/>65<rect class="sh" x="224" y="390" width="100" height="34" rx="10"/><rect class="n" x="220" y="386" width="100" height="34" rx="10"/><text class="k" x="270.0" y="407.0" text-anchor="middle">r2</text><text class="d" x="330" y="407">files, dumps</text>66<text class="k" x="600" y="266" text-anchor="end">zap</text><text class="d" x="600" y="250" text-anchor="end">kind 9735</text><path class="e" d="M556 262H432" marker-end="url(#ar)"/>67<text class="k" x="600" y="330" text-anchor="end">leave</text><text class="d" x="600" y="314" text-anchor="end">nip-77</text><path class="e" d="M432 326H556" marker-end="url(#ar)"/>68</svg>`;6970const NOTES = `<div class="notes">71<h3 id="o">durable object</h3><p>Each name has one object. The object holds the SQLite database and the relay's open sockets. The <b>owner</b> is the pubkey that signed the first <b>claim</b>. After the claim, all management is done via NIP-86 calls signed by that key. The relay page is a client of this API. The object can hibernate between frames; retained storage and scheduled work still count.</p>72<h3 id="l">lease</h3><p>A lease is a relay at a name picked for you, open to everyone, for a fixed number of days. <b>POST /lease</b> returns one; no key needed. A claim before it expires keeps it, events and files included. After that, the object is wiped and the name is free again.</p>73<h3 id="f">fuel</h3><p>Fuel prices four values: events stored, files stored, estimated hours awake and metered rows written. Mirrored site files share the file allowance; Git repositories use SQLite storage and the events allowance. Traffic is visible but uncharged. Below the allowances, you owe nothing. Above them, a zap adds balance. The Health tab and public <b>/fuel</b> show usage and prices.</p>74<h3 id="x">leave</h3><p>NIP-77 sync copies readable events to a different relay. Copy site files through Blossom and clone Git repositories separately; event sync does not copy their bytes.</p>75</div>`;7677const PROTOCOL: [string, string][] = [78 ["01", "Publish, subscribe, and query events. All clients use this."],79 ["09", "Delete your own events."],80 ["11", "The relay describes itself: limits, retention, owner, payment."],81 ["13", "The owner can require proof of work on events."],82 ["29", "The relay is one group: joins, leaves and moderation by owner and moderators, state signed by the relay."],83 ["40", "Events with an expiration tag are removed at that time."],84 ["42", "Clients prove their key. Needed for private kinds and members-only reads."],85 ["45", "Count events without downloading them."],86 ["50", "Prose search by default; the owner can index all public content or switch search off."],87 ["17, 59", "Private messages are sent only to the sender and the recipient."],88 ["62", "Request to vanish: the relay deletes all your events and refuses older ones."],89 ["70", "Protected events are accepted only from their author, over an authenticated socket."],90 ["77", "Sync events with a different relay, in either direction."],91 ["86", "Manage the relay with signed JSON-RPC calls."],92 ["98", "Sign an HTTP request with your key. Used by claim, management, and the HTTP endpoints."],93 ["05", "Members with a name are you@name.bind.ws."],94 ["43", "The relay signs and publishes its member list."],95 ["57", "Zaps to the relay add fuel. The receipt is stored on the relay."],96 ["66", "The relay signs a discovery record about itself, so directories find it without a probe."],97 ["Blossom", "Upload and fetch files by hash on the same host. Check an upload before sending it, mirror a file from a URL, report one by hash."],98 ["94, 96", "The same files through the NIP-96 door, with NIP-94 metadata in every answer."],99 ["NIP-5A", "Root, named and snapshot websites from signed manifests, on isolated hostnames. Site hosting and mirroring are on by default; the relay's read rule governs access."],100 ["Marmot", "Opt-in KeyPackages and opaque MLS group messages. Account admission and caps apply to ephemeral authors; clients handle encryption."],101 ["GRASP-01", "Opt-in public Git Smart HTTP. Signed NIP-34 state authorizes refs, and publication waits for matching Git objects. Owners can inspect retained storage. Later GRASP specifications are not advertised."],102];103104function table(heads: string[], rows: string[][], right: number[] = []): string {105 const th = heads.map((h, i) => `<th${right.includes(i) ? ' class="r"' : ""}>${escapeHTML(h)}</th>`).join("");106 const tr = rows.map((r) => "<tr>" + r.map((c, i) => `<td data-th="${escapeHTML(heads[i])}"${right.includes(i) ? ' class="r"' : ""}>${c}</td>`).join("") + "</tr>").join("\n");107 return `<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`;108}109110export function landing(req: Request, env: Env): Response {111 const domain = escapeHTML(env.DOMAIN);112 const fuel = fuelConfig(env as unknown as Record<string, unknown>);113 const p = DEFAULT_POLICY;114 const n = (x: number) => x.toLocaleString("en-US");115 const bad = new URL(req.url).searchParams.get("bad");116 const days = leaseDays(env);117 const interfaces = table(["Endpoint", "Protocol", "Auth", "Notes"], [118 [`wss://&lt;name&gt;.${domain}`, "NIP-01, 09, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 59, 62, 66, 67, 70, 77", "optional NIP-42", "Sockets hibernate; a negentropy session carries on after a wake."],119 ["GET / (accept nostr+json)", "NIP-11", "none", "limitation, retention, payments_url, self"],120 ["POST /", "NIP-86", "NIP-98, owner", "claim and the management methods"],121 ["POST /lease", "lease", "optional NIP-98", `A temporary relay for ${leaseDays(env)} days. A signature reserves the claim for that key.`],122 ["wss://relay.example.com", "custom domain", "owner", `A hostname you own, CNAME to customers.${domain}. Set on the Identity tab; up to three per relay.`],123 ["POST /events /query /count", "HTTP bridge", "NIP-98", "The signer has the same access as an authenticated socket."],124 ["PUT /upload, GET /&lt;sha256&gt;", "Blossom", "kind 24242", "Stored in R2. Counted as files. HEAD /upload checks first; PUT /mirror copies from a URL; PUT /report files a report."],125 ["POST /nip96, GET /.well-known/nostr/nip96.json", "NIP-96", "NIP-98", "Same bucket and file list as Blossom. NIP-94 tags in every answer."],126 ["GET /.well-known/nostr.json", "NIP-05", "none", "Members that have a name."],127 ["GET /e/&lt;id&gt;, /a/&lt;d&gt;, /feed.xml", "pages, Atom", "none", "Notes and articles as pages with Open Graph tags, and a feed. Only when anyone may read."],128 ["GET /card.json, /card.svg, /card.nostr", "card", "none", "A status card for links and profiles: members, rules, fuel, the group naddr as a QR. The .nostr form is signed by the relay key."],129 ["GET /fuel, POST /fuel/invoice", "NIP-57", "none", "Gauges are public. The invoice comes from a signed zap request."],130 [`https://&lt;site-label&gt;.${domain}/`, "NIP-5A", "relay read rule", "Root, named and snapshot sites, verified files and custom-domain targets. Restricted sites use browser sign-in or NIP-98."],131 ["/&lt;npub&gt;/&lt;identifier&gt;.git", "GRASP-01", "signed NIP-34 state", "Public clone, fetch and state-authorized push while enabled with open reads. The identifier is percent-encoded. Bounded hosting; storage limits remain independent of fuel balance."],132 ]);133 const limits = table(["Parameter", "Default", "Range"], [134 ["name", "3 to 32 of a-z 0-9 -", "some names are reserved"],135 ["max_message_length", `${p.maxMessageKB} KB`, `16 to ${n(MAX_MESSAGE / 1024)} KB`],136 ["max_subscriptions", String(p.maxSubs), "1 to 200"],137 ["max_limit", String(p.maxLimit), "1 to 5,000"],138 ["eventsPerMinute / reqsPerMinute", `${p.eventsPerMinute} / ${p.reqsPerMinute} per connection`, "set by the owner"],139 ["maxBlobMB", String(p.maxBlobMB), "1 to 95"],140 ["min_pow_difficulty", String(p.minPow), "set by the owner"],141 ], [2]);142 const metering = table(["Line", "Backing cost", "Free / month", "Beyond"], [143 ["events stored", "DO SQLite", `${n(fuel.freeEventsMB)} MB`, `${n(fuel.satsPerGBMonthEvents)} sats/GB-mo`],144 ["files stored", "R2", `${n(fuel.freeMediaMB)} MB`, `${n(fuel.satsPerGBMonthMedia)} sats/GB-mo`],145 ["time awake", "DO duration", `${n(fuel.freeActiveHours)} h`, `${n(fuel.satsPerActiveHour)} sats/h`],146 ["rows written", "SQLite writes", n(fuel.freeRowsWritten), `${n(fuel.satsPerMillionRows)} sats/M`],147 ["traffic, rows read", "not fuel-priced", "not billed", "free"],148 ], [2, 3]);149 const protocol = table(["NIP", "What it does here"], PROTOCOL.map(([a, b]) => [a, b.replace("name.bind.ws", `name.${domain}`)]));150 const body = `<main>151 <div class="head"><h1>${domain.replace(".", "<em>.</em>")}</h1><p>Relay on demand. Sign once, and it's yours.</p>${bad ? `<p class="note">"${escapeHTML(bad)}" is not a valid name. Use 3 to 32 lowercase letters, digits or hyphens.</p>` : ""}152 <p class="try"><button id="try" class="btn pri">Try one now</button><span id="trynote">A relay at a name picked for you, open to anyone for ${days} days. Claim it to keep it.</span></p></div>153 <div class="map"><div class="box">${MAP}</div>${NOTES}</div>154 <div class="sheet">155 <h2>Interfaces</h2>${interfaces}156 <h2>Limits</h2>${limits}157 <h2>Metering</h2>${metering}158 <p class="desc">Below the allowances, you owe nothing. With no balance past an allowance, event writes and uploads stop; GRASP requests and remote site fetches also stop. Fuel exhaustion does not delete stored data.</p>159 <p class="desc">The meters are relay totals, not a bill per feature. Git history remains in retained SQLite storage after refs expire. Provider requests and object operations are host overhead, not separate tenant charges. <a href="${REPO}/blob/main/docs/02-understanding-fuel.md">Understanding fuel</a> explains the shared allowances and metering limits.</p>160 <h2>Protocol</h2>${protocol}161 </div>162 <footer class="foot"><a href="${REPO}">-&gt; github.com/FelineStateMachine/bindws</a><span class="muted">v${VERSION}</span></footer>163</main>164<script>165document.getElementById("try").onclick = async (ev) => {166 const b = ev.target, note = document.getElementById("trynote");167 b.disabled = true; note.textContent = "Finding a name...";168 try {169 const r = await fetch("/lease", { method: "POST" });170 const j = await r.json();171 if (!r.ok) throw new Error(j.error || "no relay today");172 location.href = j.console;173 } catch (e) { note.textContent = e.message; b.disabled = false; }174};175</script>`;176 return new Response(page(env.DOMAIN, body, CSS), { headers: { "content-type": "text/html; charset=utf-8" } });177}