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 / invites.ts
invites.ts 9.9K
1// Invite links: the owner mints a code, shares https://<relay>/invite/<code>,2// and the visitor joins with one NIP-98 signature. Claiming is deliberately3// open to non-members: that is the whole point of an invite.4import { bytesToHex } from "./negentropy.ts";5import { page, escapeHTML } from "./ui.ts";6import type { Settings } from "./settings.ts";7import type { Relay } from "./relay.ts";8import { verifyNIP98 } from "./auth.ts";9import { now } from "./event.ts";1011export type Invite = {12 code: string;13 created_by: string;14 created_at: number;15 expires_at: number;16 max_uses: number; // 0 = unlimited17 uses: number;18 note: string;19};2021const DAY = 86400;2223export const CODE_RE = /^[a-zA-Z0-9_-]{4,64}$/;2425// mintInvite creates an invite. A code may be chosen by the caller (NIP-2926// create-invite and NIP-86 createclaim do); it must be unused. Returns the invite or a reason.27export function mintInvite(sql: SqlStorage, by: string, ttlSecs: number, maxUses: number, note: string, now: number, chosen = ""): Invite | string {28 if (chosen && !CODE_RE.test(chosen)) return "invalid: an invite code is 4 to 64 letters, digits, dash or underscore";29 if (chosen && sql.exec(`SELECT 1 FROM invites WHERE code=?`, chosen).toArray().length) return "duplicate: that invite code exists";30 const code = chosen || bytesToHex(crypto.getRandomValues(new Uint8Array(16)));31 const ttl = Math.min(Math.max(ttlSecs || 3 * DAY, 60), 30 * DAY);32 const inv: Invite = { code, created_by: by, created_at: now, expires_at: now + ttl, max_uses: Math.max(0, Math.min(maxUses || 0, 10_000)), uses: 0, note: note.slice(0, 200) };33 sql.exec(`INSERT INTO invites(code,created_by,created_at,expires_at,max_uses,uses,note) VALUES(?,?,?,?,?,?,?)`, inv.code, inv.created_by, inv.created_at, inv.expires_at, inv.max_uses, 0, inv.note);34 return inv;35}3637export function listInvites(sql: SqlStorage, now: number): Invite[] {38 sql.exec(`DELETE FROM invites WHERE expires_at < ?`, now - 30 * DAY);39 return sql.exec<Invite>(`SELECT * FROM invites ORDER BY created_at DESC LIMIT 200`).toArray();40}4142// listClaims returns usable NIP-43 codes, scoped before the result cap so43// another creator's invites and dead codes cannot hide a member's claims.44export function listClaims(sql: SqlStorage, now: number, by = ""): string[] {45 return sql.exec<{ code: string }>(`SELECT code FROM invites WHERE expires_at>=? AND (max_uses=0 OR uses<max_uses)${by ? " AND created_by=?" : ""} ORDER BY created_at DESC, code LIMIT 200`, now, ...(by ? [by] : [])).toArray().map((i) => i.code);46}4748export function revokeInvite(sql: SqlStorage, code: string): boolean {49 return sql.exec(`DELETE FROM invites WHERE code=?`, code).rowsWritten > 0;50}5152// inviteCreator is who minted a code, or "" for an unknown one.53export function inviteCreator(sql: SqlStorage, code: string): string {54 return sql.exec<{ created_by: string }>(`SELECT created_by FROM invites WHERE code=?`, code).toArray()[0]?.created_by ?? "";55}5657// liveInvites counts a person's invites that can still be used.58export function liveInvites(sql: SqlStorage, by: string, now: number): number {59 return sql.exec<{ n: number }>(`SELECT count(*) AS n FROM invites WHERE created_by=? AND expires_at>=? AND (max_uses=0 OR uses<max_uses)`, by, now).one().n;60}6162// memberInviteGate says whether a plain member may mint an invite under the63// owner's memberInvites rule: the tree only reaches `depth` hops from the64// owner, and each member holds at most `quota` live invites. "" allows.65export function memberInviteGate(s: Settings, sql: SqlStorage, pubkey: string, now: number): string {66 const mi = s.policy.memberInvites;67 if (mi.depth <= 0 || mi.quota <= 0) return "restricted: only the owner and moderators mint invites here";68 if (s.inviteDepth(pubkey) >= mi.depth) return "restricted: invites do not reach this far down the tree";69 if (liveInvites(sql, pubkey, now) >= mi.quota) return `restricted: you already hold ${mi.quota} live invite${mi.quota === 1 ? "" : "s"}`;70 return "";71}7273export type ClaimResult = "ok" | "invite_invalid" | "invite_expired" | "invite_exhausted";7475// checkInvite validates without consuming; claimInvite consumes one use.76export function checkInvite(sql: SqlStorage, code: string, now: number): ClaimResult {77 const inv = sql.exec<Invite>(`SELECT * FROM invites WHERE code=?`, code).toArray()[0];78 if (!inv) return "invite_invalid";79 if (inv.expires_at < now) return "invite_expired";80 if (inv.max_uses > 0 && inv.uses >= inv.max_uses) return "invite_exhausted";81 return "ok";82}8384export function claimInvite(sql: SqlStorage, code: string, now: number): ClaimResult {85 const r = checkInvite(sql, code, now);86 if (r === "ok") sql.exec(`UPDATE invites SET uses=uses+1 WHERE code=?`, code);87 return r;88}8990// invitePage is the join page. It signs the claim with a NIP-07 extension.91// termsPage is the relay's terms of service as a page: the join terms the92// owner wrote, at a stable URL that NIP-11 can point to.93export function termsPage(relayName: string, host: string, terms: string): string {94 const css = `95main { max-width: 44rem; padding-top: 10vh; }96h1 { font-size: clamp(2.6rem, 8vw, 4.2rem); }97.terms { white-space: pre-wrap; background: var(--butter); border: 2px solid var(--ink); border-radius: 14px; box-shadow: 4px 4px 0 var(--ink); padding: 1rem 1.1rem; margin: 1.2rem 0 1.5rem; }98`;99 const body = `<main>100 <h1><em>${escapeHTML(relayName)}</em> terms</h1>101 <p class="lead">What the people who run <code class="pill">wss://${escapeHTML(host)}</code> ask of everyone who uses it.</p>102 <div class="terms">${escapeHTML(terms)}</div>103 <footer class="pg"><p><a href="/">back to the relay</a></p></footer>104</main>`;105 return page(relayName + " terms", body, css);106}107108export function invitePage(relayName: string, host: string, code: string, status: ClaimResult, terms: string): string {109 const problem = { invite_invalid: "This invite link isn't valid.", invite_expired: "This invite has expired.", invite_exhausted: "This invite has been used up.", ok: "" }[status];110 const css = `111main { max-width: 44rem; padding-top: 12vh; }112h1 { font-size: clamp(3.4rem, 10vw, 5.6rem); }113.terms { white-space: pre-wrap; background: var(--butter); border: 2px solid var(--ink); border-radius: 14px; box-shadow: 4px 4px 0 var(--ink); padding: 1rem 1.1rem; margin: 1.2rem 0 1.5rem; max-height: 18rem; overflow: auto; font-size: 14.5px; }114.bad { color: var(--red); font-weight: 600; }115p.lead { margin-top: 1.2rem; }116`;117 const body = `<main>118 <h1>Join <em>${escapeHTML(relayName)}</em></h1>119 ${problem ? `<p class="lead bad">${problem}</p><p class="note">Ask whoever invited you for a fresh link.</p>` : `120 <p class="lead">You've been invited to a relay at <code class="pill">wss://${escapeHTML(host)}</code>. Joining takes one signature with your nostr extension; the relay then accepts your events and, if it's members-only, serves you its content.</p>121 ${terms ? `<h3>Before you join</h3><div class="terms">${escapeHTML(terms)}</div>` : ""}122 <p class="row"><button id="join" class="btn pri">Join with extension</button><span class="note" id="note"></span></p>123 <script>124 (() => {125 const btn = document.getElementById("join"), note = document.getElementById("note");126 const sha = async (s) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)))].map((b) => b.toString(16).padStart(2, "0")).join("");127 btn.onclick = async () => {128 if (!window.nostr) { note.textContent = "You need a nostr browser extension (Alby, nos2x, …) to sign."; return; }129 btn.disabled = true; note.textContent = "signing…";130 try {131 const url = location.origin + "/api/invites/claim";132 const body = JSON.stringify({ code: ${JSON.stringify(code)} });133 const ev = await window.nostr.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "POST"], ["payload", await sha(body)]] });134 const r = await (await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: "Nostr " + btoa(JSON.stringify(ev)) }, body })).json();135 if (r.error) throw new Error(r.error);136 note.textContent = r.status === "already_member" ? "you're already a member" : "welcome!";137 setTimeout(() => location.href = "/", 900);138 } catch (e) { note.textContent = e.message; btn.disabled = false; }139 };140 })();141 </script>`}142 <footer class="pg"><p>a bind.ws relay</p><a href="https://github.com/FelineStateMachine/bindws">source</a></footer>143</main>`;144 return page("join " + relayName, body, css);145}146147// claimInviteRequest joins the NIP-98 signer through an invite code. It is148// open to non-members by design; that is what an invite is for.149export async function claimInviteRequest(relay: Relay, req: Request): Promise<Response> {150 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });151 const body = await req.text();152 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, body);153 if (typeof auth === "string") return json({ error: auth }, 401);154 let code = "";155 try {156 code = String((JSON.parse(body) as { code?: unknown }).code ?? "");157 } catch {158 return json({ error: "invalid: body is not JSON" }, 400);159 }160 if (relay.settings.policy.owner === "") return json({ error: "invite_invalid" }, 403);161 if (relay.settings.isBanned(auth.pubkey)) return json({ error: "blocked: this pubkey is banned from this relay" }, 403);162 if (relay.settings.isAllowed(auth.pubkey)) return json({ status: "already_member", role: relay.settings.isOwner(auth.pubkey) ? "owner" : "member" });163 const r = claimInvite(relay.sql, code, now());164 if (r !== "ok") return json({ error: r }, 403);165 relay.settings.upsertMember(auth.pubkey, { via: "invite " + code.slice(0, 8), invitedBy: inviteCreator(relay.sql, code) }, now());166 await relay.publishMembership({ pubkey: auth.pubkey, added: true });167 return json({ status: "joined", role: "member" });168}