clone

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

nipad: web URLs resolve to live Nostr counterparts under existing access rules (#22)

FelineStateMachine committed 03f39d4
Path discovery maps relay groups, public event pages and hosted site manifests to exact filters with explicit relay hints. Shared resolvers preserve browser path matching, NIP-05 behavior and site isolation; docs pin the upstream proposal and tests cover access and routing boundaries.
commit
03f39d463314656de34e898bda89e36157b41ed1
parent
edb792b450c28a2c30c022aaf9066f0d9f8739bd
author
FelineStateMachine <dami.etoile@gmail.com>
12 files changed, +616 -24
MREADME.md1+
Mdocs/14-http-reference.md8+-
Mdocs/20-nip-5a-static-websites.md6+
Adocs/23-nip-ad-web-addresses.md102+++++++++++++++++
Asrc/nipad.ts66+++++++++++
Msrc/pages.ts26+++-
Msrc/routes.ts6+-
Msrc/sites.ts70++++++++++--
Mtest/object/exposure.test.ts5+
Atest/object/nipad.test.ts176++++++++++++++++++++++++++++++
Atest/object/site-addresses.test.ts169+++++++++++++++++++++++++++++
Mtest/object/site-auth.test.ts5+
M README.md +1 -0
M docs/14-http-reference.md +7 -1
@@ -7,7 +7,11 @@ audience: integrator
77 88 Every path the worker and a relay answer. Paths are on `https://<name>.bind.ws` unless marked apex. One read rule covers every door that shows events, files, names or presence: where the auth column says "the read rule", a relay whose reads are *anyone* answers without a signature, one whose reads are *signed in* takes any valid signature, and one whose reads are *members* takes a member's. Auth column: "NIP-98" is a signed request (see [Scripts and agents](13-scripts-and-agents.md)); "Blossom" is a kind 24242 token in the Authorization header; "none" is public. JSON errors carry `{ "error": "<prefix>: reason" }`; Blossom and NIP-96 errors also set an `X-Reason` header.99 10-Any request with `Accept: application/nostr+json` answers the NIP-11 document, whatever the path. A websocket upgrade on any path opens the relay. A blocked address is refused with 403 on the socket and on the doors that write, read or serve files; the page, NIP-11 and management stay open.10+Requests with `Accept: application/nostr+json` answer the NIP-11 document,11+except explicit NIP-AD path discovery. A websocket upgrade on any relay12+path opens the relay. A blocked address is refused with 403 on the socket13+and on the doors that write, read or serve files; the page, NIP-11 and14+management stay open.1115 1216 ## Apex1317
@@ -27,6 +31,7 @@ Any request with `Accept: application/nostr+json` answers the NIP-11 document, w
2731 | `/` | GET with `Accept: application/nostr+json` | none | NIP-11: rules, limits, retention, `self`, `lease` while leased, `succession_pending` while warning | 200 |2832 | `/` | POST, `content-type: application/nostr+json+rpc` | NIP-98 | NIP-86 management, `{ result }` or `{ error }` | 200; 400 invalid; 401 bad signature; 403 not allowed; 409 conflict; 429 active relay operation |2933 | `/people` | GET | none | `{ public, self, host, people }`, the members when the directory is public | 200 |34+| `/.well-known/nostr.json?path=<pathname>` | GET, HEAD; OPTIONS preflight | the read rule for the group; public pages only | NIP-AD path-keyed `{ filter, relays }`, or `{}` without a mapping; [NIP-AD web addresses](23-nip-ad-web-addresses.md) | 200; 400 invalid pathname; 401/403 group read rule; 405 other methods |3035 | `/terms` | GET | none | the join terms as a page | 200; 404 none set |3136 | `/signer.js` | GET | none | the NIP-46 client bundle, cached a week | 200 |3237 | `/favicon.svg` | GET | none | the icon | 200 |
@@ -53,6 +58,7 @@ follows the `sites` feature and the relay's read rule.
5358 | `/` or a site path | other methods or websocket upgrade | none | method error | 405 |5459 | `/.well-known/nsite/auth` | GET | none, or NIP-98 when continuing an API session | NIP-07 sign-in HTML with a five-minute challenge | 401; 429 too many outstanding challenges |5560 | `/.well-known/nsite/auth` | POST | exact NIP-98 for this URL, method and body; body contains the signed kind 22242 challenge response | sets the seven-day `__Host-nsite` cookie | 204; 401 invalid or expired proof; 403 read rule; 413 body over 32 KiB |61+| `/.well-known/nostr.json?path=<pathname>` | GET, HEAD; OPTIONS preflight | the site's read rule | NIP-AD mapping to the live manifest with its hosting relay hint; no file fetch | 200; 204 preflight; 400 invalid pathname; 401/403 read rule; 404 unavailable site; 405 other methods |5662 5763 Site responses include `Content-Type`, `Content-Length` when supplied by the5864 source, an ETag equal to the file hash, `X-Content-Type-Options: nosniff`, and
M docs/20-nip-5a-static-websites.md +6 -0
A docs/23-nip-ad-web-addresses.md +102 -0
@@ -0,0 +1,102 @@
1+---2+title: NIP-AD web addresses3+audience: user4+---5+6+# NIP-AD web addresses7+8+A relay or site URL works in a browser and resolves to a Nostr event in9+clients that support NIP-AD. bind.ws follows [proposal 2406](https://github.com/nostr-protocol/nips/pull/2406),10+[`AD.md` at b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed](https://github.com/nostr-protocol/nips/blob/b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed/AD.md),11+checked 2026-09-04. The proposed text labels itself `final` and `optional`,12+but the pull request is still a proposal. Its wire format may change.13+14+## Resolve a URL15+16+A supporting client takes the URL's pathname and requests17+`https://<host>/.well-known/nostr.json?path=<encoded-pathname>`. Encode the18+pathname as a query parameter, preserving any escapes already in the URL:19+`/a/hello%20world` becomes `path=%2Fa%2Fhello%2520world`.20+21+For `https://alice.bind.ws/a/why-relays`, the response has this shape:22+23+```json24+{25+ "/a/why-relays": {26+ "filter": {27+ "kinds": [30023],28+ "authors": ["<owner-pubkey>"],29+ "#d": ["why-relays"],30+ "limit": 131+ },32+ "relays": ["wss://alice.bind.ws"]33+ }34+}35+```36+37+The client queries the named relay with that filter. A browser opening the38+original URL continues to receive the existing page. Notes and articles39+also have an "Open in a nostr client" link.40+41+| Browser URL | Nostr counterpart |42+|---|---|43+| Relay `/` | Relay-signed kind 39000, with the relay identity and group identifier |44+| Relay `/e/<id>` | That exact note or article event ID, including its version |45+| Relay `/a/<d>` | Owner's current kind 30023 article with that identifier |46+| Relay `/a/<author>/<d>` | That author's current article; hex, npub and nprofile authors follow the browser page parser |47+| Site `/`, a mapped file or directory | Current kind 15128 or 35128 manifest, or exact kind 5128 snapshot |48+49+All filters include `limit: 1` and explicit relay hints. Address filters50+include the full author, kind and, for addressable events, `#d`. Event ID51+filters use the full ID. Site hints name the relay holding the manifest,52+not the site hostname. Custom domains follow their selected relay or site;53+local development retains `ws://` and its port.54+55+## Visibility and errors56+57+Discovery applies the target's existing access rules:58+59+| Target | Rule |60+|---|---|61+| Relay group | Current read rule; private metadata requires an admitted NIP-98 identity |62+| Note or article | Open reads and the `pages` feature; signing does not open private pages |63+| Site manifest | `sites` feature, site authentication and the current read rule |64+| Removed, expired or moderation-hidden event | No mapping |65+| Unclaimed or expired relay | No mapping |66+67+Successful lookups return only the requested path. Unknown paths return68+`{}`; discovery does not return the member directory or enumerate paths.69+Site paths must appear in the live manifest, directly or as a directory's70+`index.html`. A `/404.html` fallback does not give a missing path a mapping.71+Discovery reads manifest metadata without fetching or verifying file bytes;72+the normal site request still verifies and serves the file.73+74+Private group and site requests return 401 without authentication or 40375+for a proved identity the read rule excludes. Site visitors retain the76+existing sign-in flow and host-only cookie; API clients can sign the exact77+discovery URL with NIP-98. Site-origin discovery never exposes the hosting78+relay's NIP-05 names, console, WebSocket, event pages or management methods.79+Blocked IP addresses remain blocked.80+81+GET returns JSON, HEAD returns the same status and headers without a body,82+and OPTIONS supports discovery preflight without resolving a path.83+Discovery responses use CORS and84+`Cache-Control: private, no-store` so policy changes are checked each time.85+Unsupported methods return 405. Invalid GET or HEAD paths return 400: only one absolute86+pathname of at most 4096 characters is accepted, without literal queries,87+fragments, whitespace, control characters, backslashes or dot segments.88+Percent-encoded spaces and other article identifier characters work when89+the pathname is correctly query-encoded. Full URLs are not accepted and90+discovery never fetches an external URL.91+92+## Names and capabilities93+94+The existing NIP-05 endpoint keeps its behavior. With `name`, including a95+request that also has `path`, it answers the name lookup. Without either96+parameter it answers the permitted member directory. Turning off `names`97+disables those NIP-05 responses; path discovery remains available. Turning98+off `pages` or `sites` removes their respective mappings.99+100+This implementation leaves the NIP-11 `supported_nips` representation101+unchanged. Lettered capability advertisement is coordinated separately102+with NIP-9a and NIP-5A compatibility work.
A src/nipad.ts +66 -0
@@ -0,0 +1,66 @@
1+// NIP-AD web addresses: a browser path names a live Nostr event on this2+// relay. Discovery only reads local state; no supplied path becomes a fetch.3+// Proposal revision: b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed (PR 2406).4+import { whoAsks, denyStatus } from "./auth.ts";5+import { isAddressable, isReplaceable, now, tag, type Event } from "./event.ts";6+import { KIND_GROUP_METADATA } from "./kinds.ts";7+import { pageEvent } from "./pages.ts";8+import type { Relay } from "./relay.ts";9+import { featureOn } from "./settings.ts";10+11+// A name lookup retains NIP-05 semantics even when a path is also supplied.12+export const isWebAddressRequest = (url: URL): boolean => url.pathname === "/.well-known/nostr.json" && url.searchParams.has("path") && !url.searchParams.has("name");13+14+// requestedPath keeps the original escaped spelling as the response key.15+// Full URLs, queries, fragments, dot segments and ambiguous separators are16+// not pathnames. One decoding pass matches the browser page and site doors.17+export function requestedPath(url: URL): string | null {18+ const paths = url.searchParams.getAll("path");19+ if (paths.length !== 1) return null;20+ const path = paths[0];21+ if (path.length > 4096 || !path.startsWith("/") || path.startsWith("//") || /[\\?#\s\x00-\x1f\x7f]/.test(path)) return null;22+ let decoded: string;23+ try { decoded = decodeURIComponent(path); } catch { return null; }24+ if (/[\\\x00-\x1f\x7f]/.test(decoded) || decoded.startsWith("//") || decoded.split("/").some((s) => s === "." || s === "..")) return null;25+ return path;26+}27+28+export function eventFilter(e: Event): Record<string, unknown> {29+ if (isAddressable(e.kind)) return { kinds: [e.kind], authors: [e.pubkey], "#d": [tag(e, "d")], limit: 1 };30+ if (isReplaceable(e.kind)) return { kinds: [e.kind], authors: [e.pubkey], limit: 1 };31+ return { ids: [e.id], limit: 1 };32+}33+34+export function webAddressResponse(req: Request, path: string | null, filter: Record<string, unknown> | null, relayURL: string, status = 200): Response {35+ const headers = {36+ "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store",37+ "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD, OPTIONS",38+ "access-control-allow-headers": "authorization, accept", "x-content-type-options": "nosniff",39+ ...(status === 405 ? { allow: "GET, HEAD, OPTIONS" } : {}),40+ };41+ const body = path !== null && filter ? { [path]: { filter, relays: [relayURL] } } : {};42+ return new Response(req.method === "HEAD" || req.method === "OPTIONS" ? null : JSON.stringify(body), { status, headers });43+}44+45+export function webAddress(relay: Relay, req: Request, url: URL): Response {46+ const relayURL = relay.relayURL(url.host);47+ const reply = (path: string | null, filter: Record<string, unknown> | null = null, status = 200) => webAddressResponse(req, path, filter, relayURL, status);48+ if (req.method === "OPTIONS") return reply(null);49+ if (req.method !== "GET" && req.method !== "HEAD") return reply(null, null, 405);50+ const path = requestedPath(url);51+ if (path === null) return reply(null, null, 400);52+ const who = whoAsks(req, "", null);53+ if (typeof who === "string") return reply(null, null, 401);54+ if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return reply(null);55+ if (path === "/") {56+ const gate = relay.settings.mayRead(who.pubkeys);57+ if (gate) return reply(null, null, denyStatus(gate));58+ const authors = [relay.identity.pubkey];59+ const kinds = [KIND_GROUP_METADATA];60+ const raw = relay.store.query({ authors, kinds, tags: { d: [relay.slug] }, limit: 1 }, who, 1, now()).rows[0];61+ return reply(path, raw ? eventFilter(JSON.parse(raw) as Event) : null);62+ }63+ const e = featureOn(relay.settings.policy, "pages") ? pageEvent(relay, path) : null;64+ // An /e URL always names that exact version, including an article version.65+ return reply(path, e ? (path.startsWith("/e/") ? { ids: [e.id], limit: 1 } : eventFilter(e)) : null);66+}
M src/pages.ts +18 -8
@@ -27,18 +27,28 @@ export function pages(relay: Relay, req: Request): Response {
2727 if (relay.settings.isUnclaimed()) return nothing(relay, "Nobody has claimed this relay yet.");2828 if (relay.settings.policy.reads !== "open") return nothing(relay, "This relay's events are for its members.");2929 if (url.pathname === "/feed.xml") return feed(relay, url, origin);30- const parts = url.pathname.split("/").filter(Boolean);31- let e: Event | null = null;32- if (parts[0] === "e" && parts.length === 2 && HEX64.test(parts[1])) e = one(relay, { ids: [parts[1]], kinds: KINDS, tags: {} });33- else if (parts[0] === "a" && parts.length === 2) e = one(relay, { kinds: [30023], authors: [relay.settings.policy.owner], tags: { d: [decodeURIComponent(parts[1])] } });34- else if (parts[0] === "a" && parts.length === 3) {35- const author = pubkeyOf(parts[1]);36- if (author) e = one(relay, { kinds: [30023], authors: [author], tags: { d: [decodeURIComponent(parts[2])] } });37- }30+ const e = pageEvent(relay, url.pathname);3831 if (!e) return nothing(relay, "Nothing by that name here.");3932 return new Response(render(relay, e, origin), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });4033 }4134 35+// pageEvent selects the same live, public event for HTML and web addresses.36+export function pageEvent(relay: Relay, path: string): Event | null {37+ if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now()) || relay.settings.policy.reads !== "open") return null;38+ const parts = path.split("/").filter(Boolean);39+ let e: Event | null = null;40+ // A malformed escape names no page, rather than raising a URIError.41+ try {42+ if (parts[0] === "e" && parts.length === 2 && HEX64.test(parts[1])) e = one(relay, { ids: [parts[1]], kinds: KINDS, tags: {} });43+ else if (parts[0] === "a" && parts.length === 2) e = one(relay, { kinds: [30023], authors: [relay.settings.policy.owner], tags: { d: [decodeURIComponent(parts[1])] } });44+ else if (parts[0] === "a" && parts.length === 3) {45+ const author = pubkeyOf(parts[1]);46+ if (author) e = one(relay, { kinds: [30023], authors: [author], tags: { d: [decodeURIComponent(parts[2])] } });47+ }48+ } catch { return null; }49+ return e;50+}51+4252 function nothing(relay: Relay, why: string): Response {4353 const body = `<main><h1>Not here</h1><p class="lead">${escapeHTML(why)}</p><footer class="pg"><p><a href="/">${escapeHTML(relay.settings.policy.name || relay.slug)}</a></p></footer></main>`;4454 return new Response(page("not here", body, PAGE_CSS), { status: 404, headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });
M src/routes.ts +5 -1
M src/sites.ts +56 -14
@@ -12,6 +12,7 @@ import { denyStatus } from "./auth.ts";
1212 import { featureOn } from "./settings.ts";1313 import { remoteSiteBlob } from "./site-mirror.ts";1414 import { blobBlocked, type Blob } from "./blossom.ts";15+import { eventFilter, isWebAddressRequest, requestedPath, webAddressResponse } from "./nipad.ts";1516 1617 export const SITE_KINDS = [KIND_SITE, KIND_NAMED_SITE, KIND_SITE_SNAPSHOT];1718 const HEX = /^[0-9a-f]{64}$/;
@@ -153,33 +154,74 @@ const SITE_TYPES: Record<string, string> = {
153154 };154155 export const siteType = (path: string) => SITE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";155156 const siteError = (req: Request, status: number, message: string) => new Response(req.method === "HEAD" ? null : message, { status, headers: { "cache-control": "private, no-store", "content-type": "text/plain; charset=utf-8" } });157+const discoveryError = (req: Request, status: number, message: string) => {158+ const response = siteError(req, status, message);159+ const headers = new Headers(response.headers);160+ headers.set("access-control-allow-origin", "*");161+ return new Response(response.body, { status: response.status, headers });162+};163+164+// siteRelayURL gives a discovery document the relay address behind a hosted165+// site. A custom site hostname must never become the relay hint.166+const siteRelayURL = (relay: Relay, url: URL): string => {167+ const host = url.hostname.endsWith(".localhost")168+ ? `${relay.slug}.localhost${url.port ? ":" + url.port : ""}`169+ : `${relay.slug}.${relay.domain}`;170+ return relay.relayURL(host);171+};172+173+// sitePath selects the browser's exact escaped filename before its decoded174+// counterpart. Discovery follows a directory's redirect to its index page.175+function sitePath(e: Event, pathname: string): { redirect: boolean; mapping: string[] | undefined } | null {176+ let decoded: string;177+ try { decoded = decodeURIComponent(pathname); } catch { return null; }178+ const directory = !/[^/]+\.[^/.]+$/.test(decoded);179+ const redirect = directory && !decoded.endsWith("/");180+ if (redirect) { pathname += "/"; decoded += "/"; }181+ if (directory) decoded += "index.html";182+ const paths = sitePaths(e);183+ return { redirect, mapping: paths.find((t) => t[1] === pathname) ?? paths.find((t) => t[1] === decoded) };184+}156185 157186 // serveSite answers the site origin, under the read rule. Relay handlers158187 // and content negotiation never run here, even on paths the relay uses.159188 export async function serveSite(relay: Relay, req: Request, label: string): Promise<Response> {160189 const site = parseSite(label);161- const host = new URL(req.url).hostname;190+ const url = new URL(req.url);191+ const discovery = isWebAddressRequest(url);192+ const fail = (status: number, message: string) => discovery ? discoveryError(req, status, message) : siteError(req, status, message);193+ const host = url.hostname;162194 const canonical = host === `${label}.${relay.domain.toLowerCase()}` || host === `${label}.localhost`;163- if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return siteError(req, 404, "Not found");164- if (!site || !featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return siteError(req, 404, "Not found");165- if (req.headers.get("upgrade") || (!["GET", "HEAD"].includes(req.method) && !(new URL(req.url).pathname === SITE_AUTH_PATH && req.method === "POST"))) return siteError(req, 405, "Method not allowed");195+ if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return fail(404, "Not found");196+ if (!site || !featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return fail(404, "Not found");197+ if (discovery && req.method === "OPTIONS") {198+ return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, HEAD, OPTIONS", "cache-control": "no-store" } });199+ }200+ if (req.headers.get("upgrade") || (!["GET", "HEAD"].includes(req.method) && !(url.pathname === SITE_AUTH_PATH && req.method === "POST"))) {201+ return discovery ? webAddressResponse(req, null, null, siteRelayURL(relay, url), 405) : siteError(req, 405, "Method not allowed");202+ }166203 const who = await siteIdentity(relay, req, label);167- if (who instanceof Response) return who;204+ if (who instanceof Response) return discovery ? (() => { const headers = new Headers(who.headers); headers.set("access-control-allow-origin", "*"); return new Response(who.body, { status: who.status, headers }); })() : who;168205 const gate = relay.settings.mayRead(who.pubkeys);169- if (gate) return siteError(req, denyStatus(gate), gate);206+ if (gate) return fail(denyStatus(gate), gate);170207 const e = manifest(relay, site);171- if (!e) return siteError(req, 404, "Not found");172- const url = new URL(req.url);173- let path: string;174- try { path = decodeURIComponent(url.pathname); } catch { return siteError(req, 404, "Not found"); }175- const directory = !/[^/]+\.[^/.]+$/.test(path);176- if (directory && !path.endsWith("/")) {208+ if (!e) return discovery ? webAddressResponse(req, requestedPath(url), null, siteRelayURL(relay, url), 404) : siteError(req, 404, "Not found");209+ if (discovery) {210+ if (!featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return fail(404, "Not found");211+ if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return fail(404, "Not found");212+ const path = requestedPath(url);213+ if (path === null) return webAddressResponse(req, null, null, siteRelayURL(relay, url), 400);214+ const mapping = sitePath(e, path)?.mapping;215+ return webAddressResponse(req, path, mapping && !blobBlocked(relay, mapping[2]) ? eventFilter(e) : null, siteRelayURL(relay, url));216+ }217+ const selected = sitePath(e, url.pathname);218+ if (!selected) return siteError(req, 404, "Not found");219+ if (selected.redirect) {177220 url.pathname += "/";178221 return new Response(null, { status: 308, headers: { location: url.href, "cache-control": "private, no-store" } });179222 }180- if (directory) path += "index.html";181223 const paths = sitePaths(e);182- let mapping = paths.find((t) => t[1] === url.pathname) ?? paths.find((t) => t[1] === path);224+ let mapping = selected.mapping;183225 let status = 200;184226 if (!mapping) { mapping = paths.find((t) => t[1] === "/404.html"); status = 404; }185227 if (!mapping || blobBlocked(relay, mapping[2])) return siteError(req, 404, "Not found");
M test/object/exposure.test.ts +5 -0
A test/object/nipad.test.ts +176 -0
@@ -0,0 +1,176 @@
1+// NIP-AD path discovery shares the relay's page and metadata boundaries.2+import { SELF, env, runInDurableObject } from "cloudflare:test";3+import { describe, it, expect } from "vitest";4+import { generateSecretKey } from "nostr-tools/pure";5+import { npubEncode, nprofileEncode } from "nostr-tools/nip19";6+import { ev, info, nip98, now, pk, post, rpc } from "../helpers/relay.ts";7+import { WS } from "../helpers/ws.ts";8+9+const endpoint = (host: string, path: string) => `http://${host}/.well-known/nostr.json?path=${encodeURIComponent(path)}`;10+const lookup = async (host: string, path: string, signer?: Uint8Array) => {11+ const url = endpoint(host, path);12+ const response = await SELF.fetch(url, { headers: signer ? { authorization: await nip98(signer, url) } : {} });13+ return { status: response.status, body: await response.json<Record<string, { filter: Record<string, unknown>; relays: string[] }>>() };14+};15+16+describe("NIP-AD web addresses", () => {17+ it("maps the homepage to this relay's exact live group, with an explicit relay hint", async () => {18+ const host = "ad-group.bind.ws", owner = generateSecretKey();19+ await rpc(host, owner, "claim");20+ const self = (await info(host)).self;21+ const filter = { kinds: [39000], authors: [self], "#d": ["ad-group"], limit: 1 };22+ const r = await lookup(host, "/");23+ expect(r).toEqual({ status: 200, body: { "/": { filter, relays: [`wss://${host}`] } } });24+ const queried = await post(host, owner, "/query", [filter]);25+ expect(queried.body).toHaveLength(1);26+ expect(queried.body[0]).toMatchObject({ kind: 39000, pubkey: self });27+ await runInDurableObject(env.RELAY.getByName("ad-group"), (relay) => {28+ const row = relay.sql.exec<{ id: string }>("SELECT id FROM events WHERE kind=39000").one();29+ relay.store.hidden.add(row.id);30+ });31+ expect((await lookup(host, "/")).body).toEqual({});32+ });33+34+ it("keeps exact event versions and addressable articles aligned with the browser pages", async () => {35+ const host = "ad-pages.bind.ws", owner = generateSecretKey(), author = generateSecretKey();36+ await rpc(host, owner, "claim");37+ const c = await WS.connect(host);38+ const note = ev(owner, 1, "A web address");39+ const d = "why / Nostr? #1%";40+ const article = ev(owner, 30023, "An article", [["d", d]], now() - 1);41+ const other = ev(author, 30023, "Another author", [["d", d]]);42+ for (const e of [note, article, other]) expect((await c.ok(e)).ok).toBe(true);43+ const cases = [44+ { path: `/e/${note.id}`, filter: { ids: [note.id], limit: 1 } },45+ { path: `/e/${article.id}`, filter: { ids: [article.id], limit: 1 } },46+ ...[`/a/${encodeURIComponent(d)}`, `/a/${pk(owner)}/${encodeURIComponent(d)}`, `/a/${npubEncode(pk(owner))}/${encodeURIComponent(d)}`, `/a/${nprofileEncode({ pubkey: pk(owner) })}/${encodeURIComponent(d)}`]47+ .map((path) => ({ path, filter: { kinds: [30023], authors: [pk(owner)], "#d": [d], limit: 1 } })),48+ { path: `/a/${pk(author)}/${encodeURIComponent(d)}`, filter: { kinds: [30023], authors: [pk(author)], "#d": [d], limit: 1 } },49+ ];50+ for (const { path, filter } of cases) {51+ expect((await SELF.fetch(`http://${host}${path}`)).status, path).toBe(200);52+ expect((await lookup(host, path)).body).toEqual({ [path]: { filter, relays: [`wss://${host}`] } });53+ }54+ const next = ev(owner, 30023, "New edition", [["d", d]]);55+ expect((await c.ok(next)).ok).toBe(true);56+ expect((await lookup(host, `/e/${article.id}`)).body).toEqual({});57+ const address = (await lookup(host, `/a/${encodeURIComponent(d)}`)).body;58+ expect(address[`/a/${encodeURIComponent(d)}`].filter).toEqual(cases[2].filter);59+ expect((await post(host, owner, "/query", [cases[2].filter])).body.map((e: { id: string }) => e.id)).toEqual([next.id]);60+ c.ws.close();61+ });62+63+ it("omits missing, deleted, expired, held and private events and unsupported browser paths", async () => {64+ const host = "ad-hidden.bind.ws", owner = generateSecretKey();65+ await rpc(host, owner, "claim");66+ const c = await WS.connect(host);67+ const deleted = ev(owner, 1, "deleted"), held = ev(owner, 1, "held");68+ const expiring = ev(owner, 1, "expires", [["expiration", String(now() + 60)]]);69+ const dm = ev(owner, 4, "private", [["p", pk(owner)]]);70+ const profile = ev(owner, 0, "{}");71+ const article = ev(owner, 30023, "gone", [["d", "gone"]]);72+ for (const e of [deleted, held, expiring, dm, profile, article]) expect((await c.ok(e)).ok).toBe(true);73+ expect((await c.ok(ev(owner, 5, "", [["e", deleted.id], ["a", `30023:${pk(owner)}:gone`]]))).ok).toBe(true);74+ await runInDurableObject(env.RELAY.getByName("ad-hidden"), (relay) => {75+ relay.store.hidden.add(held.id);76+ relay.sql.exec("UPDATE events SET expires=? WHERE id=?", now() - 1, expiring.id);77+ });78+ for (const path of [deleted, held, expiring, dm, profile].map((e) => `/e/${e.id}`).concat([`/e/${"0".repeat(64)}`, "/a/gone", "/a/missing", "/people", "/feed.xml", "/view/articles", "/card.json", "/e/nope"])) {79+ expect((await lookup(host, path)).body, path).toEqual({});80+ }81+ c.ws.close();82+ });83+84+ it("preserves private metadata admission and never opens public pages with authentication", async () => {85+ const host = "ad-private.bind.ws", owner = generateSecretKey(), outsider = generateSecretKey();86+ await rpc(host, owner, "claim");87+ const c = await WS.connect(host), note = ev(owner, 1, "members only");88+ expect((await c.ok(note)).ok).toBe(true);89+ await rpc(host, owner, "setpolicy", { reads: "members", directoryPublic: false });90+ expect(await lookup(host, "/")).toEqual({ status: 401, body: {} });91+ expect(await lookup(host, "/", outsider)).toEqual({ status: 403, body: {} });92+ expect((await lookup(host, "/", owner)).body["/"].filter).toMatchObject({ kinds: [39000] });93+ for (const signer of [undefined, outsider, owner]) expect((await lookup(host, `/e/${note.id}`, signer)).body).toEqual({});94+ await rpc(host, owner, "setpolicy", { reads: "auth" });95+ expect((await lookup(host, "/")).status).toBe(401);96+ expect((await lookup(host, "/", outsider)).body["/"]).toBeDefined();97+ await rpc(host, owner, "setpolicy", { reads: "open" });98+ expect((await lookup(host, "/")).body["/"]).toBeDefined();99+ c.ws.close();100+ });101+102+ it("preserves name lookup and directory semantics independently of path and page discovery", async () => {103+ const host = "ad-names.bind.ws", owner = generateSecretKey();104+ await rpc(host, owner, "claim");105+ await rpc(host, owner, "setmember", pk(owner), { name: "alice" });106+ const wellKnown = `http://${host}/.well-known/nostr.json`;107+ const names = { names: { alice: pk(owner) }, relays: { [pk(owner)]: [`wss://${host}`] } };108+ expect(await (await SELF.fetch(wellKnown)).json()).toEqual(names);109+ expect(await (await SELF.fetch(wellKnown + "?name=alice&path=/")).json()).toEqual(names);110+ await rpc(host, owner, "setpolicy", { directoryPublic: false });111+ expect(await (await SELF.fetch(wellKnown)).json()).toEqual({ names: {}, relays: {} });112+ expect(await (await SELF.fetch(wellKnown + "?name=alice")).json()).toEqual(names);113+ const c = await WS.connect(host), note = ev(owner, 1, "public page");114+ expect((await c.ok(note)).ok).toBe(true);115+ await rpc(host, owner, "setpolicy", { features: { names: false } });116+ expect((await SELF.fetch(wellKnown + "?name=alice")).status).toBe(404);117+ expect((await SELF.fetch(wellKnown + "?name=alice&path=/")).status).toBe(404);118+ expect((await lookup(host, "/")).body["/"]).toBeDefined();119+ expect((await lookup(host, `/e/${note.id}`)).body[`/e/${note.id}`]).toBeDefined();120+ await rpc(host, owner, "setpolicy", { features: { pages: false, sites: false } });121+ expect((await lookup(host, `/e/${note.id}`)).body).toEqual({});122+ expect((await lookup(host, "/")).body["/"]).toBeDefined();123+ c.ws.close();124+ });125+126+ it("answers explicit discovery before content negotiation with CORS, HEAD and narrow preflight", async () => {127+ const host = "ad-http.bind.ws", owner = generateSecretKey();128+ await rpc(host, owner, "claim");129+ const url = endpoint(host, "/");130+ const res = await SELF.fetch(url, { headers: { accept: "application/nostr+json" } });131+ expect((await res.json<Record<string, unknown>>())["/"]).toBeDefined();132+ expect(res.headers.get("cache-control")).toContain("no-store");133+ expect(res.headers.get("access-control-allow-origin")).toBe("*");134+ for (const method of ["HEAD", "OPTIONS"]) {135+ const r = await SELF.fetch(url, { method });136+ expect(r.status).toBe(200);137+ expect(await r.text()).toBe("");138+ expect(r.headers.get("access-control-allow-headers")).toContain("authorization");139+ }140+ for (const method of ["POST", "PUT", "DELETE"]) expect((await SELF.fetch(url, { method })).status).toBe(405);141+ const bad = await SELF.fetch(url, { headers: { authorization: "Nostr broken" } });142+ expect(bad.status).toBe(401);143+ expect(await bad.json()).toEqual({});144+ });145+146+ it("rejects malformed, oversized and external path inputs without resolving another URL", async () => {147+ const host = "ad-paths.bind.ws", owner = generateSecretKey();148+ await rpc(host, owner, "claim");149+ for (const path of ["", "a", "https://example.com/a", "//example.com/a", "/a?x=1", "/a#one", "/a/%", "/a/%ff", "/a/../secret", "/a/%2e%2e/secret", "/a\\secret", "/a/%5csecret", "/a/%00", "/a\n", "/" + "a".repeat(4096)]) {150+ expect(await lookup(host, path), path).toEqual({ status: 400, body: {} });151+ }152+ expect((await SELF.fetch(endpoint(host, "/") + "&path=/a/other")).status).toBe(400);153+ expect((await SELF.fetch(`http://${host}/a/%`)).status).toBe(404);154+ });155+156+ it("uses the routed custom relay host and preserves local WebSocket schemes and ports", async () => {157+ const owner = generateSecretKey();158+ await rpc("ad-alias.bind.ws", owner, "claim");159+ await env.HOSTS.put("relay.ad-example.test", "ad-alias");160+ expect((await lookup("relay.ad-example.test", "/")).body["/"].relays).toEqual(["wss://relay.ad-example.test"]);161+ await rpc("ad-local.localhost:8787", owner, "claim");162+ expect((await lookup("ad-local.localhost:8787", "/")).body["/"].relays).toEqual(["ws://ad-local.localhost:8787"]);163+ });164+165+ it("omits unclaimed or expired relays and refuses blocked IP addresses", async () => {166+ expect((await lookup("ad-unclaimed.bind.ws", "/")).body).toEqual({});167+ const host = "ad-blocked.bind.ws", owner = generateSecretKey();168+ await rpc(host, owner, "claim");169+ expect((await rpc(host, owner, "blockip", "203.0.113.55", "scraper")).status).toBe(200);170+ expect((await SELF.fetch(endpoint(host, "/"), { headers: { "cf-connecting-ip": "203.0.113.55" } })).status).toBe(403);171+ await runInDurableObject(env.RELAY.getByName("ad-blocked"), (relay) => {172+ relay.settings.update({ owner: "", lease: { until: now() - 1, holder: "" } });173+ });174+ expect((await lookup(host, "/")).body).toEqual({});175+ });176+});
A test/object/site-addresses.test.ts +169 -0
@@ -0,0 +1,169 @@
1+import { SELF, env, runInDurableObject } from "cloudflare:test";2+import { describe, expect, it } from "vitest";3+import { generateSecretKey } from "nostr-tools/pure";4+import { aggregate, siteLabel } from "../../src/sites.ts";5+import { upload } from "../helpers/media.ts";6+import { ev, nip98, now, pk, rpc } from "../helpers/relay.ts";7+import { WS } from "../helpers/ws.ts";8+9+describe("site web addresses", () => {10+ it("maps the root and browser directory paths to manifest events", async () => {11+ const name = "site-addresses", host = `${name}.bind.ws`, sk = generateSecretKey();12+ await rpc(host, sk, "claim");13+ const file = await upload(host, sk, "site address");14+ const c = await WS.connect(host);15+ const site = ev(sk, 15128, "", [["path", "/index.html", file.sha], ["path", "/docs/index.html", file.sha], ["path", "/404.html", file.sha]]);16+ const named = ev(sk, 35128, "", [["d", "docs"], ["path", "/index.html", file.sha]]);17+ const snapshot = ev(sk, 5128, "", [["path", "/index.html", file.sha], ["a", `15128:${site.pubkey}:`], ["x", aggregate({ tags: [["path", "/index.html", file.sha], ["a", `15128:${site.pubkey}:`]] }), "aggregate"]]);18+ expect(await c.ok(site)).toMatchObject({ ok: true });19+ expect(await c.ok(named)).toMatchObject({ ok: true });20+ expect(await c.ok(snapshot)).toMatchObject({ ok: true });21+ await runInDurableObject(env.RELAY.getByName(name), (relay) => relay.syncSites());22+ const address = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`;23+ const root = await SELF.fetch(address + "?path=%2F");24+ expect(root.status).toBe(200);25+ expect(root.headers.get("access-control-allow-origin")).toBe("*");26+ expect(root.headers.get("cache-control")).toContain("no-store");27+ expect(await root.json()).toEqual({ "/": { filter: { authors: [site.pubkey], kinds: [15128], limit: 1 }, relays: [`wss://${name}.bind.ws`] } });28+ expect(await (await SELF.fetch(address + "?path=%2Fdocs")).json()).toEqual({ "/docs": { filter: { authors: [site.pubkey], kinds: [15128], limit: 1 }, relays: [`wss://${name}.bind.ws`] } });29+ expect(await (await SELF.fetch(address + "?path=%2Fmissing.html")).json()).toEqual({});30+ const namedURL = `http://${siteLabel(named)}.bind.ws/.well-known/nostr.json?path=%2F`;31+ expect(await (await SELF.fetch(namedURL)).json()).toEqual({ "/": { filter: { authors: [named.pubkey], kinds: [35128], "#d": ["docs"], limit: 1 }, relays: [`wss://${name}.bind.ws`] } });32+ const snapshotURL = `http://${siteLabel(snapshot)}.bind.ws/.well-known/nostr.json?path=%2F`;33+ expect(await (await SELF.fetch(snapshotURL)).json()).toEqual({ "/": { filter: { ids: [snapshot.id], limit: 1 }, relays: [`wss://${name}.bind.ws`] } });34+ const head = await SELF.fetch(address + "?path=%2F", { method: "HEAD" });35+ expect(head.status).toBe(200);36+ expect(await head.text()).toBe("");37+ expect((await SELF.fetch(address + "?path=%2F", { method: "POST" })).status).toBe(405);38+ expect((await SELF.fetch(address + "?path=%2F&path=%2Fdocs")).status).toBe(400);39+ expect((await SELF.fetch(address + "?path=%2F%2e%2e%2Fx")).status).toBe(400);40+ c.ws.close();41+ });42+43+ it("keeps NIP-05 names and read authentication separate from path discovery", async () => {44+ const name = "site-address-auth", host = `${name}.bind.ws`, sk = generateSecretKey();45+ await rpc(host, sk, "claim");46+ const c = await WS.connect(host);47+ const site = ev(sk, 35128, "", [["d", "docs"], ["path", "/index.html", "ab".repeat(32)]]);48+ expect((await c.ok(site)).ok).toBe(true);49+ await rpc(host, sk, "setpolicy", { reads: "members" });50+ const address = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`;51+ expect((await SELF.fetch(address + "?name=nobodyq7")).status).toBe(401);52+ expect((await SELF.fetch(address + "?path=%2F")).status).toBe(401);53+ const options = await SELF.fetch(address + "?path=%2F", { method: "OPTIONS" });54+ expect(options.status).toBe(204);55+ expect(options.headers.get("access-control-allow-origin")).toBe("*");56+ c.ws.close();57+ });58+59+ it("requires an exact NIP-98 URL and applies the site read rule", async () => {60+ const name = "site-address-proof", host = `${name}.bind.ws`, owner = generateSecretKey(), outsider = generateSecretKey();61+ await rpc(host, owner, "claim");62+ const c = await WS.connect(host);63+ const site = ev(owner, 15128, "", [["path", "/index.html", "ab".repeat(32)]]);64+ expect((await c.ok(site)).ok).toBe(true); c.ws.close();65+ await rpc(host, owner, "setpolicy", { reads: "members" });66+ const url = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json?path=%2F`;67+ const bad = await SELF.fetch(url, { headers: { authorization: await nip98(outsider, url + "x") } });68+ expect(bad.status).toBe(401);69+ const denied = await SELF.fetch(url, { headers: { authorization: await nip98(outsider, url) } });70+ expect(denied.status).toBe(403);71+ const allowed = await SELF.fetch(url, { headers: { authorization: await nip98(owner, url) } });72+ expect(allowed.status).toBe(200);73+ });74+75+ it("keeps encoded filenames ahead of their decoded spelling and resolves encoded directories", async () => {76+ const name = "site-address-escaped", host = `${name}.bind.ws`, sk = generateSecretKey();77+ await rpc(host, sk, "claim");78+ const file = await upload(host, sk, "escaped"), blocked = await upload(host, sk, "blocked escaped");79+ const c = await WS.connect(host);80+ const site = ev(sk, 15128, "", [["path", "/hello world.html", file.sha], ["path", "/hello%20world.html", blocked.sha], ["path", "/docs space/index.html", file.sha], ["path", "/only%20escaped/index.html", file.sha], ["path", "/100%.html", file.sha]]);81+ expect((await c.ok(site)).ok).toBe(true); c.ws.close();82+ const url = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`;83+ const encoded = await SELF.fetch(url + "?path=%2Fhello%2520world.html");84+ expect(encoded.status).toBe(200);85+ expect(Object.keys(await encoded.json())).toEqual(["/hello%20world.html"]);86+ const directory = await SELF.fetch(url + "?path=%2Fdocs%2520space%2F");87+ expect(directory.status).toBe(200);88+ expect(Object.keys(await directory.json())).toEqual(["/docs%20space/"]);89+ expect(await (await SELF.fetch(`http://${siteLabel(site)}.bind.ws/docs%20space/`)).text()).toBe("escaped");90+ expect(await (await SELF.fetch(url + "?path=%2Fonly%2520escaped%2F")).json()).toEqual({});91+ expect(Object.keys(await (await SELF.fetch(url + "?path=%2F100%2525.html")).json())).toEqual(["/100%25.html"]);92+ // The encoded filename wins even when the decoded tag comes first.93+ await runInDurableObject(env.RELAY.getByName(name), (relay) => { relay.settings.setEvent(blocked.sha, "ban", "moderated"); });94+ expect(await (await SELF.fetch(url + "?path=%2Fhello%2520world.html")).json()).toEqual({});95+ expect((await SELF.fetch(`http://${siteLabel(site)}.bind.ws/hello%20world.html`)).status).toBe(404);96+ });97+98+ it("preserves custom site targets, canonical relay hints and stale-edge isolation", async () => {99+ const name = "site-address-custom", host = `${name}.bind.ws`, sk = generateSecretKey();100+ const custom = "site.ad-custom.test";101+ await rpc(host, sk, "claim");102+ const file = await upload(host, sk, "custom site");103+ const c = await WS.connect(host), site = ev(sk, 15128, "", [["path", "/index.html", file.sha]]);104+ expect(await c.ok(site)).toMatchObject({ ok: true }); c.ws.close();105+ const label = siteLabel(site);106+ // The edge still has its old relay-only mapping when the local target changes.107+ await env.HOSTS.put(custom, name);108+ await runInDurableObject(env.RELAY.getByName(name), (relay) => {109+ relay.settings.update({ customHosts: [{ host: custom, id: "custom-1", site: label, at: now(), status: "active", sslStatus: "active" }] });110+ });111+ const url = `http://${custom}/.well-known/nostr.json?path=%2F`;112+ const expected = { "/": { filter: { kinds: [15128], authors: [pk(sk)], limit: 1 }, relays: [`wss://${host}`] } };113+ expect(await (await SELF.fetch(url, { headers: { accept: "application/nostr+json" } })).json()).toEqual(expected);114+ for (const path of ["/people", "/e/" + site.id, "/.well-known/nostr.json?name=alice"]) expect((await SELF.fetch(`http://${custom}${path}`)).status).toBe(404);115+ expect((await SELF.fetch(`http://${custom}/`, { headers: { upgrade: "websocket" } })).status).toBe(405);116+ const localURL = `http://${label}.localhost:8787/.well-known/nostr.json?path=%2F`;117+ expect(await (await SELF.fetch(localURL)).json()).toEqual({ "/": { ...expected["/"], relays: [`ws://${name}.localhost:8787`] } });118+ // A stale site edge mapping must not reopen either a removed site or the relay.119+ const stale = "stale.ad-custom.test";120+ await env.HOSTS.put(stale, JSON.stringify({ name, site: label }));121+ expect((await SELF.fetch(`http://${stale}/.well-known/nostr.json?path=%2F`)).status).toBe(404);122+ });123+124+ it("keeps hosted nostr.json bytes for name lookups without exposing the relay directory", async () => {125+ const name = "site-address-names", host = `${name}.bind.ws`, sk = generateSecretKey();126+ await rpc(host, sk, "claim");127+ await rpc(host, sk, "setmember", pk(sk), { name: "relay-owner" });128+ const bytes = JSON.stringify({ names: { website: "ab".repeat(32) } });129+ const file = await upload(host, sk, bytes);130+ const c = await WS.connect(host);131+ const site = ev(sk, 15128, "", [["path", "/index.html", file.sha], ["path", "/.well-known/nostr.json", file.sha]]);132+ expect(await c.ok(site)).toMatchObject({ ok: true }); c.ws.close();133+ const url = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`;134+ for (const query of ["", "?name=website", "?name=relay-owner&path=%2F"]) expect(await (await SELF.fetch(url + query)).text()).toBe(bytes);135+ const mapping = await (await SELF.fetch(url + "?path=%2F")).json<Record<string, unknown>>();136+ expect(Object.keys(mapping)).toEqual(["/"]);137+ expect(JSON.stringify(mapping)).not.toContain("relay-owner");138+ });139+140+ it("hides unavailable manifests and switched-off sites even with a cached host route", async () => {141+ const name = "site-address-hidden", host = `${name}.bind.ws`, sk = generateSecretKey();142+ await rpc(host, sk, "claim");143+ const file = await upload(host, sk, "live site");144+ const c = await WS.connect(host), site = ev(sk, 15128, "", [["path", "/index.html", file.sha]]);145+ expect(await c.ok(site)).toMatchObject({ ok: true }); c.ws.close();146+ const label = siteLabel(site), url = `http://${label}.bind.ws/.well-known/nostr.json?path=%2F`;147+ expect((await SELF.fetch(url)).status).toBe(200);148+ await rpc(host, sk, "setpolicy", { features: { sites: false } });149+ expect((await SELF.fetch(url)).status).toBe(404);150+ await rpc(host, sk, "setpolicy", { features: { sites: true } });151+ await runInDurableObject(env.RELAY.getByName(name), (relay) => { relay.store.hidden.add(site.id); });152+ expect((await SELF.fetch(url)).status).toBe(404);153+ await runInDurableObject(env.RELAY.getByName(name), (relay) => {154+ relay.store.hidden.delete(site.id);155+ relay.sql.exec("UPDATE events SET expires=? WHERE id=?", now() - 1, site.id);156+ });157+ expect((await SELF.fetch(url)).status).toBe(404);158+ await runInDurableObject(env.RELAY.getByName(name), (relay) => {159+ relay.sql.exec("UPDATE events SET expires=0 WHERE id=?", site.id);160+ relay.settings.update({ owner: "", lease: { holder: "", until: now() - 1 } });161+ });162+ expect((await SELF.fetch(url)).status).toBe(404);163+ await runInDurableObject(env.RELAY.getByName(name), (relay) => {164+ relay.settings.update({ owner: pk(sk), lease: null });165+ relay.sql.exec("DELETE FROM events WHERE id=?", site.id);166+ });167+ expect((await SELF.fetch(url)).status).toBe(404);168+ });169+});
M test/object/site-auth.test.ts +5 -0