From e011247c76875319636222b96cc9fa63d2abe633 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 17:48:43 -0600 Subject: [PATCH] nipad: web URLs resolve to live Nostr counterparts under existing access rules 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. --- README.md | 1 + docs/14-http-reference.md | 8 +- docs/20-nip-5a-static-websites.md | 6 + docs/23-nip-ad-web-addresses.md | 102 +++++++++++++++++ src/nipad.ts | 66 +++++++++++ src/pages.ts | 26 +++-- src/routes.ts | 6 +- src/sites.ts | 70 +++++++++--- test/object/exposure.test.ts | 5 + test/object/nipad.test.ts | 176 +++++++++++++++++++++++++++++ test/object/site-addresses.test.ts | 169 +++++++++++++++++++++++++++ test/object/site-auth.test.ts | 5 + 12 files changed, 616 insertions(+), 24 deletions(-) create mode 100644 docs/23-nip-ad-web-addresses.md create mode 100644 src/nipad.ts create mode 100644 test/object/nipad.test.ts create mode 100644 test/object/site-addresses.test.ts diff --git a/README.md b/README.md index 98c036d..a501f9f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena ### Draft NIPs - [NIP-5A static websites](docs/20-nip-5a-static-websites.md): publish and serve static sites from signed Nostr manifests. +- [NIP-AD web addresses](docs/23-nip-ad-web-addresses.md): resolve relay, article and site URLs to their Nostr counterparts. - [GRASP-01 Git hosting](docs/22-grasp-01-git-hosting.md): host bounded NIP-34 repositories through Git Smart HTTP. ### Protocol guides diff --git a/docs/14-http-reference.md b/docs/14-http-reference.md index 3539f2e..aeab35c 100644 --- a/docs/14-http-reference.md +++ b/docs/14-http-reference.md @@ -7,7 +7,11 @@ audience: integrator Every path the worker and a relay answer. Paths are on `https://.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": ": reason" }`; Blossom and NIP-96 errors also set an `X-Reason` header. -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. +Requests with `Accept: application/nostr+json` answer the NIP-11 document, +except explicit NIP-AD path discovery. A websocket upgrade on any relay +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. ## Apex @@ -27,6 +31,7 @@ Any request with `Accept: application/nostr+json` answers the NIP-11 document, w | `/` | GET with `Accept: application/nostr+json` | none | NIP-11: rules, limits, retention, `self`, `lease` while leased, `succession_pending` while warning | 200 | | `/` | 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 | | `/people` | GET | none | `{ public, self, host, people }`, the members when the directory is public | 200 | +| `/.well-known/nostr.json?path=` | 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 | | `/terms` | GET | none | the join terms as a page | 200; 404 none set | | `/signer.js` | GET | none | the NIP-46 client bundle, cached a week | 200 | | `/favicon.svg` | GET | none | the icon | 200 | @@ -53,6 +58,7 @@ follows the `sites` feature and the relay's read rule. | `/` or a site path | other methods or websocket upgrade | none | method error | 405 | | `/.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 | | `/.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 | +| `/.well-known/nostr.json?path=` | 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 | Site responses include `Content-Type`, `Content-Length` when supplied by the source, an ETag equal to the file hash, `X-Content-Type-Options: nosniff`, and diff --git a/docs/20-nip-5a-static-websites.md b/docs/20-nip-5a-static-websites.md index e5ec338..7fbc1c0 100644 --- a/docs/20-nip-5a-static-websites.md +++ b/docs/20-nip-5a-static-websites.md @@ -117,6 +117,12 @@ retry; replacing a manifest does not delete and recreate its hostname. ## Deliberate limits +Supporting Nostr clients can resolve a hosted site URL through +[NIP-AD web addresses](23-nip-ad-web-addresses.md). Discovery maps existing +site paths to the live manifest under the same site authentication and read +rules, with the hosting relay as its hint. Custom site domains retain their +site origin; discovery does not open the hosting relay's other HTTP doors. + bind.ws does not advertise NIP-5A in `supported_nips`: NIP-11 still defines that field as integer identifiers, while 5A is a lettered draft and does not ask relays to advertise it. The relay advertises `nsites` instead. Follow diff --git a/docs/23-nip-ad-web-addresses.md b/docs/23-nip-ad-web-addresses.md new file mode 100644 index 0000000..290a101 --- /dev/null +++ b/docs/23-nip-ad-web-addresses.md @@ -0,0 +1,102 @@ +--- +title: NIP-AD web addresses +audience: user +--- + +# NIP-AD web addresses + +A relay or site URL works in a browser and resolves to a Nostr event in +clients that support NIP-AD. bind.ws follows [proposal 2406](https://github.com/nostr-protocol/nips/pull/2406), +[`AD.md` at b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed](https://github.com/nostr-protocol/nips/blob/b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed/AD.md), +checked 2026-09-04. The proposed text labels itself `final` and `optional`, +but the pull request is still a proposal. Its wire format may change. + +## Resolve a URL + +A supporting client takes the URL's pathname and requests +`https:///.well-known/nostr.json?path=`. Encode the +pathname as a query parameter, preserving any escapes already in the URL: +`/a/hello%20world` becomes `path=%2Fa%2Fhello%2520world`. + +For `https://alice.bind.ws/a/why-relays`, the response has this shape: + +```json +{ + "/a/why-relays": { + "filter": { + "kinds": [30023], + "authors": [""], + "#d": ["why-relays"], + "limit": 1 + }, + "relays": ["wss://alice.bind.ws"] + } +} +``` + +The client queries the named relay with that filter. A browser opening the +original URL continues to receive the existing page. Notes and articles +also have an "Open in a nostr client" link. + +| Browser URL | Nostr counterpart | +|---|---| +| Relay `/` | Relay-signed kind 39000, with the relay identity and group identifier | +| Relay `/e/` | That exact note or article event ID, including its version | +| Relay `/a/` | Owner's current kind 30023 article with that identifier | +| Relay `/a//` | That author's current article; hex, npub and nprofile authors follow the browser page parser | +| Site `/`, a mapped file or directory | Current kind 15128 or 35128 manifest, or exact kind 5128 snapshot | + +All filters include `limit: 1` and explicit relay hints. Address filters +include the full author, kind and, for addressable events, `#d`. Event ID +filters use the full ID. Site hints name the relay holding the manifest, +not the site hostname. Custom domains follow their selected relay or site; +local development retains `ws://` and its port. + +## Visibility and errors + +Discovery applies the target's existing access rules: + +| Target | Rule | +|---|---| +| Relay group | Current read rule; private metadata requires an admitted NIP-98 identity | +| Note or article | Open reads and the `pages` feature; signing does not open private pages | +| Site manifest | `sites` feature, site authentication and the current read rule | +| Removed, expired or moderation-hidden event | No mapping | +| Unclaimed or expired relay | No mapping | + +Successful lookups return only the requested path. Unknown paths return +`{}`; discovery does not return the member directory or enumerate paths. +Site paths must appear in the live manifest, directly or as a directory's +`index.html`. A `/404.html` fallback does not give a missing path a mapping. +Discovery reads manifest metadata without fetching or verifying file bytes; +the normal site request still verifies and serves the file. + +Private group and site requests return 401 without authentication or 403 +for a proved identity the read rule excludes. Site visitors retain the +existing sign-in flow and host-only cookie; API clients can sign the exact +discovery URL with NIP-98. Site-origin discovery never exposes the hosting +relay's NIP-05 names, console, WebSocket, event pages or management methods. +Blocked IP addresses remain blocked. + +GET returns JSON, HEAD returns the same status and headers without a body, +and OPTIONS supports discovery preflight without resolving a path. +Discovery responses use CORS and +`Cache-Control: private, no-store` so policy changes are checked each time. +Unsupported methods return 405. Invalid GET or HEAD paths return 400: only one absolute +pathname of at most 4096 characters is accepted, without literal queries, +fragments, whitespace, control characters, backslashes or dot segments. +Percent-encoded spaces and other article identifier characters work when +the pathname is correctly query-encoded. Full URLs are not accepted and +discovery never fetches an external URL. + +## Names and capabilities + +The existing NIP-05 endpoint keeps its behavior. With `name`, including a +request that also has `path`, it answers the name lookup. Without either +parameter it answers the permitted member directory. Turning off `names` +disables those NIP-05 responses; path discovery remains available. Turning +off `pages` or `sites` removes their respective mappings. + +This implementation leaves the NIP-11 `supported_nips` representation +unchanged. Lettered capability advertisement is coordinated separately +with NIP-9a and NIP-5A compatibility work. diff --git a/src/nipad.ts b/src/nipad.ts new file mode 100644 index 0000000..fd00214 --- /dev/null +++ b/src/nipad.ts @@ -0,0 +1,66 @@ +// NIP-AD web addresses: a browser path names a live Nostr event on this +// relay. Discovery only reads local state; no supplied path becomes a fetch. +// Proposal revision: b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed (PR 2406). +import { whoAsks, denyStatus } from "./auth.ts"; +import { isAddressable, isReplaceable, now, tag, type Event } from "./event.ts"; +import { KIND_GROUP_METADATA } from "./kinds.ts"; +import { pageEvent } from "./pages.ts"; +import type { Relay } from "./relay.ts"; +import { featureOn } from "./settings.ts"; + +// A name lookup retains NIP-05 semantics even when a path is also supplied. +export const isWebAddressRequest = (url: URL): boolean => url.pathname === "/.well-known/nostr.json" && url.searchParams.has("path") && !url.searchParams.has("name"); + +// requestedPath keeps the original escaped spelling as the response key. +// Full URLs, queries, fragments, dot segments and ambiguous separators are +// not pathnames. One decoding pass matches the browser page and site doors. +export function requestedPath(url: URL): string | null { + const paths = url.searchParams.getAll("path"); + if (paths.length !== 1) return null; + const path = paths[0]; + if (path.length > 4096 || !path.startsWith("/") || path.startsWith("//") || /[\\?#\s\x00-\x1f\x7f]/.test(path)) return null; + let decoded: string; + try { decoded = decodeURIComponent(path); } catch { return null; } + if (/[\\\x00-\x1f\x7f]/.test(decoded) || decoded.startsWith("//") || decoded.split("/").some((s) => s === "." || s === "..")) return null; + return path; +} + +export function eventFilter(e: Event): Record { + if (isAddressable(e.kind)) return { kinds: [e.kind], authors: [e.pubkey], "#d": [tag(e, "d")], limit: 1 }; + if (isReplaceable(e.kind)) return { kinds: [e.kind], authors: [e.pubkey], limit: 1 }; + return { ids: [e.id], limit: 1 }; +} + +export function webAddressResponse(req: Request, path: string | null, filter: Record | null, relayURL: string, status = 200): Response { + const headers = { + "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store", + "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD, OPTIONS", + "access-control-allow-headers": "authorization, accept", "x-content-type-options": "nosniff", + ...(status === 405 ? { allow: "GET, HEAD, OPTIONS" } : {}), + }; + const body = path !== null && filter ? { [path]: { filter, relays: [relayURL] } } : {}; + return new Response(req.method === "HEAD" || req.method === "OPTIONS" ? null : JSON.stringify(body), { status, headers }); +} + +export function webAddress(relay: Relay, req: Request, url: URL): Response { + const relayURL = relay.relayURL(url.host); + const reply = (path: string | null, filter: Record | null = null, status = 200) => webAddressResponse(req, path, filter, relayURL, status); + if (req.method === "OPTIONS") return reply(null); + if (req.method !== "GET" && req.method !== "HEAD") return reply(null, null, 405); + const path = requestedPath(url); + if (path === null) return reply(null, null, 400); + const who = whoAsks(req, "", null); + if (typeof who === "string") return reply(null, null, 401); + if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return reply(null); + if (path === "/") { + const gate = relay.settings.mayRead(who.pubkeys); + if (gate) return reply(null, null, denyStatus(gate)); + const authors = [relay.identity.pubkey]; + const kinds = [KIND_GROUP_METADATA]; + const raw = relay.store.query({ authors, kinds, tags: { d: [relay.slug] }, limit: 1 }, who, 1, now()).rows[0]; + return reply(path, raw ? eventFilter(JSON.parse(raw) as Event) : null); + } + const e = featureOn(relay.settings.policy, "pages") ? pageEvent(relay, path) : null; + // An /e URL always names that exact version, including an article version. + return reply(path, e ? (path.startsWith("/e/") ? { ids: [e.id], limit: 1 } : eventFilter(e)) : null); +} diff --git a/src/pages.ts b/src/pages.ts index bb47a7a..e663158 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -27,18 +27,28 @@ export function pages(relay: Relay, req: Request): Response { if (relay.settings.isUnclaimed()) return nothing(relay, "Nobody has claimed this relay yet."); if (relay.settings.policy.reads !== "open") return nothing(relay, "This relay's events are for its members."); if (url.pathname === "/feed.xml") return feed(relay, url, origin); - const parts = url.pathname.split("/").filter(Boolean); - let e: Event | null = null; - if (parts[0] === "e" && parts.length === 2 && HEX64.test(parts[1])) e = one(relay, { ids: [parts[1]], kinds: KINDS, tags: {} }); - else if (parts[0] === "a" && parts.length === 2) e = one(relay, { kinds: [30023], authors: [relay.settings.policy.owner], tags: { d: [decodeURIComponent(parts[1])] } }); - else if (parts[0] === "a" && parts.length === 3) { - const author = pubkeyOf(parts[1]); - if (author) e = one(relay, { kinds: [30023], authors: [author], tags: { d: [decodeURIComponent(parts[2])] } }); - } + const e = pageEvent(relay, url.pathname); if (!e) return nothing(relay, "Nothing by that name here."); return new Response(render(relay, e, origin), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } }); } +// pageEvent selects the same live, public event for HTML and web addresses. +export function pageEvent(relay: Relay, path: string): Event | null { + if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now()) || relay.settings.policy.reads !== "open") return null; + const parts = path.split("/").filter(Boolean); + let e: Event | null = null; + // A malformed escape names no page, rather than raising a URIError. + try { + if (parts[0] === "e" && parts.length === 2 && HEX64.test(parts[1])) e = one(relay, { ids: [parts[1]], kinds: KINDS, tags: {} }); + else if (parts[0] === "a" && parts.length === 2) e = one(relay, { kinds: [30023], authors: [relay.settings.policy.owner], tags: { d: [decodeURIComponent(parts[1])] } }); + else if (parts[0] === "a" && parts.length === 3) { + const author = pubkeyOf(parts[1]); + if (author) e = one(relay, { kinds: [30023], authors: [author], tags: { d: [decodeURIComponent(parts[2])] } }); + } + } catch { return null; } + return e; +} + function nothing(relay: Relay, why: string): Response { const body = `

Not here

${escapeHTML(why)}

`; return new Response(page("not here", body, PAGE_CSS), { status: 404, headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } }); diff --git a/src/routes.ts b/src/routes.ts index 7265e07..59e2aff 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -11,6 +11,7 @@ import { manage } from "./manage.ts"; import { bridge } from "./bridge.ts"; import { nip11 } from "./nip11.ts"; import { nip05Document } from "./nip05.ts"; +import { isWebAddressRequest, webAddress } from "./nipad.ts"; import { verifyNIP98, whoAsks } from "./auth.ts"; import { checkInvite, claimInviteRequest, invitePage, termsPage } from "./invites.ts"; import { dumpDownload } from "./dumps.ts"; @@ -46,7 +47,10 @@ export const isManagementRequest = (req: Request) => req.method === "POST" && (r export const ROUTES: Route[] = [ { when: isGitPath, gated: true, answer: grasp }, - // NIP-11, on any path, by the accept header. + // Explicit path discovery wins over content negotiation, independently + // of member names. The handler applies each target's visibility policy. + { when: isWebAddressRequest, gated: true, answer: webAddress }, + // NIP-11, after explicit discovery, by the accept header. { when: (_, req) => req.headers.get("accept")?.includes("application/nostr+json") ?? false, answer: (relay, _, url) => Response.json(nip11(relay, url.host), { headers: { "content-type": "application/nostr+json", ...CORS } }), diff --git a/src/sites.ts b/src/sites.ts index 53c85a2..743cb0d 100644 --- a/src/sites.ts +++ b/src/sites.ts @@ -12,6 +12,7 @@ import { denyStatus } from "./auth.ts"; import { featureOn } from "./settings.ts"; import { remoteSiteBlob } from "./site-mirror.ts"; import { blobBlocked, type Blob } from "./blossom.ts"; +import { eventFilter, isWebAddressRequest, requestedPath, webAddressResponse } from "./nipad.ts"; export const SITE_KINDS = [KIND_SITE, KIND_NAMED_SITE, KIND_SITE_SNAPSHOT]; const HEX = /^[0-9a-f]{64}$/; @@ -153,33 +154,74 @@ const SITE_TYPES: Record = { }; export const siteType = (path: string) => SITE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream"; 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" } }); +const discoveryError = (req: Request, status: number, message: string) => { + const response = siteError(req, status, message); + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + return new Response(response.body, { status: response.status, headers }); +}; + +// siteRelayURL gives a discovery document the relay address behind a hosted +// site. A custom site hostname must never become the relay hint. +const siteRelayURL = (relay: Relay, url: URL): string => { + const host = url.hostname.endsWith(".localhost") + ? `${relay.slug}.localhost${url.port ? ":" + url.port : ""}` + : `${relay.slug}.${relay.domain}`; + return relay.relayURL(host); +}; + +// sitePath selects the browser's exact escaped filename before its decoded +// counterpart. Discovery follows a directory's redirect to its index page. +function sitePath(e: Event, pathname: string): { redirect: boolean; mapping: string[] | undefined } | null { + let decoded: string; + try { decoded = decodeURIComponent(pathname); } catch { return null; } + const directory = !/[^/]+\.[^/.]+$/.test(decoded); + const redirect = directory && !decoded.endsWith("/"); + if (redirect) { pathname += "/"; decoded += "/"; } + if (directory) decoded += "index.html"; + const paths = sitePaths(e); + return { redirect, mapping: paths.find((t) => t[1] === pathname) ?? paths.find((t) => t[1] === decoded) }; +} // serveSite answers the site origin, under the read rule. Relay handlers // and content negotiation never run here, even on paths the relay uses. export async function serveSite(relay: Relay, req: Request, label: string): Promise { const site = parseSite(label); - const host = new URL(req.url).hostname; + const url = new URL(req.url); + const discovery = isWebAddressRequest(url); + const fail = (status: number, message: string) => discovery ? discoveryError(req, status, message) : siteError(req, status, message); + const host = url.hostname; const canonical = host === `${label}.${relay.domain.toLowerCase()}` || host === `${label}.localhost`; - if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return siteError(req, 404, "Not found"); - if (!site || !featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return siteError(req, 404, "Not found"); - 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"); + if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return fail(404, "Not found"); + if (!site || !featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return fail(404, "Not found"); + if (discovery && req.method === "OPTIONS") { + 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" } }); + } + if (req.headers.get("upgrade") || (!["GET", "HEAD"].includes(req.method) && !(url.pathname === SITE_AUTH_PATH && req.method === "POST"))) { + return discovery ? webAddressResponse(req, null, null, siteRelayURL(relay, url), 405) : siteError(req, 405, "Method not allowed"); + } const who = await siteIdentity(relay, req, label); - if (who instanceof Response) return who; + 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; const gate = relay.settings.mayRead(who.pubkeys); - if (gate) return siteError(req, denyStatus(gate), gate); + if (gate) return fail(denyStatus(gate), gate); const e = manifest(relay, site); - if (!e) return siteError(req, 404, "Not found"); - const url = new URL(req.url); - let path: string; - try { path = decodeURIComponent(url.pathname); } catch { return siteError(req, 404, "Not found"); } - const directory = !/[^/]+\.[^/.]+$/.test(path); - if (directory && !path.endsWith("/")) { + if (!e) return discovery ? webAddressResponse(req, requestedPath(url), null, siteRelayURL(relay, url), 404) : siteError(req, 404, "Not found"); + if (discovery) { + if (!featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return fail(404, "Not found"); + if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return fail(404, "Not found"); + const path = requestedPath(url); + if (path === null) return webAddressResponse(req, null, null, siteRelayURL(relay, url), 400); + const mapping = sitePath(e, path)?.mapping; + return webAddressResponse(req, path, mapping && !blobBlocked(relay, mapping[2]) ? eventFilter(e) : null, siteRelayURL(relay, url)); + } + const selected = sitePath(e, url.pathname); + if (!selected) return siteError(req, 404, "Not found"); + if (selected.redirect) { url.pathname += "/"; return new Response(null, { status: 308, headers: { location: url.href, "cache-control": "private, no-store" } }); } - if (directory) path += "index.html"; const paths = sitePaths(e); - let mapping = paths.find((t) => t[1] === url.pathname) ?? paths.find((t) => t[1] === path); + let mapping = selected.mapping; let status = 200; if (!mapping) { mapping = paths.find((t) => t[1] === "/404.html"); status = 404; } if (!mapping || blobBlocked(relay, mapping[2])) return siteError(req, 404, "Not found"); diff --git a/test/object/exposure.test.ts b/test/object/exposure.test.ts index b3c565c..f6ab43e 100644 --- a/test/object/exposure.test.ts +++ b/test/object/exposure.test.ts @@ -55,10 +55,15 @@ function doors(f: Fixture): { path: string; host?: string; method?: string; gate { host: f.siteHost, path: "/", gated: true }, { host: f.siteHost, path: "/index.html", method: "HEAD", gated: true }, { host: f.siteHost, path: "/.well-known/nsite/auth", gated: true }, + { host: f.siteHost, path: "/.well-known/nostr.json?path=%2F", gated: true }, + { host: f.siteHost, path: "/.well-known/nostr.json?path=%2Findex.html", method: "HEAD", gated: true }, { path: "/", gated: false }, { path: "/people", gated: false }, { path: "/.well-known/nostr.json", gated: false }, { path: "/.well-known/nostr.json?name=nobodyq7", gated: false }, + { path: "/.well-known/nostr.json?path=%2F", gated: true }, + { path: `/.well-known/nostr.json?path=${encodeURIComponent(`/e/${f.noteId}`)}`, gated: false }, + { path: `/.well-known/nostr.json?path=${encodeURIComponent(`/a/${pk(f.eve)}/private`)}`, gated: false }, { path: `/list/${pk(f.eve)}`, gated: true, blossom: "list" }, { path: `/${f.sha}`, gated: true, blossom: "get" }, { path: `/${f.sha}`, method: "HEAD", gated: true, blossom: "get" }, diff --git a/test/object/nipad.test.ts b/test/object/nipad.test.ts new file mode 100644 index 0000000..31f4931 --- /dev/null +++ b/test/object/nipad.test.ts @@ -0,0 +1,176 @@ +// NIP-AD path discovery shares the relay's page and metadata boundaries. +import { SELF, env, runInDurableObject } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import { npubEncode, nprofileEncode } from "nostr-tools/nip19"; +import { ev, info, nip98, now, pk, post, rpc } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +const endpoint = (host: string, path: string) => `http://${host}/.well-known/nostr.json?path=${encodeURIComponent(path)}`; +const lookup = async (host: string, path: string, signer?: Uint8Array) => { + const url = endpoint(host, path); + const response = await SELF.fetch(url, { headers: signer ? { authorization: await nip98(signer, url) } : {} }); + return { status: response.status, body: await response.json; relays: string[] }>>() }; +}; + +describe("NIP-AD web addresses", () => { + it("maps the homepage to this relay's exact live group, with an explicit relay hint", async () => { + const host = "ad-group.bind.ws", owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const self = (await info(host)).self; + const filter = { kinds: [39000], authors: [self], "#d": ["ad-group"], limit: 1 }; + const r = await lookup(host, "/"); + expect(r).toEqual({ status: 200, body: { "/": { filter, relays: [`wss://${host}`] } } }); + const queried = await post(host, owner, "/query", [filter]); + expect(queried.body).toHaveLength(1); + expect(queried.body[0]).toMatchObject({ kind: 39000, pubkey: self }); + await runInDurableObject(env.RELAY.getByName("ad-group"), (relay) => { + const row = relay.sql.exec<{ id: string }>("SELECT id FROM events WHERE kind=39000").one(); + relay.store.hidden.add(row.id); + }); + expect((await lookup(host, "/")).body).toEqual({}); + }); + + it("keeps exact event versions and addressable articles aligned with the browser pages", async () => { + const host = "ad-pages.bind.ws", owner = generateSecretKey(), author = generateSecretKey(); + await rpc(host, owner, "claim"); + const c = await WS.connect(host); + const note = ev(owner, 1, "A web address"); + const d = "why / Nostr? #1%"; + const article = ev(owner, 30023, "An article", [["d", d]], now() - 1); + const other = ev(author, 30023, "Another author", [["d", d]]); + for (const e of [note, article, other]) expect((await c.ok(e)).ok).toBe(true); + const cases = [ + { path: `/e/${note.id}`, filter: { ids: [note.id], limit: 1 } }, + { path: `/e/${article.id}`, filter: { ids: [article.id], limit: 1 } }, + ...[`/a/${encodeURIComponent(d)}`, `/a/${pk(owner)}/${encodeURIComponent(d)}`, `/a/${npubEncode(pk(owner))}/${encodeURIComponent(d)}`, `/a/${nprofileEncode({ pubkey: pk(owner) })}/${encodeURIComponent(d)}`] + .map((path) => ({ path, filter: { kinds: [30023], authors: [pk(owner)], "#d": [d], limit: 1 } })), + { path: `/a/${pk(author)}/${encodeURIComponent(d)}`, filter: { kinds: [30023], authors: [pk(author)], "#d": [d], limit: 1 } }, + ]; + for (const { path, filter } of cases) { + expect((await SELF.fetch(`http://${host}${path}`)).status, path).toBe(200); + expect((await lookup(host, path)).body).toEqual({ [path]: { filter, relays: [`wss://${host}`] } }); + } + const next = ev(owner, 30023, "New edition", [["d", d]]); + expect((await c.ok(next)).ok).toBe(true); + expect((await lookup(host, `/e/${article.id}`)).body).toEqual({}); + const address = (await lookup(host, `/a/${encodeURIComponent(d)}`)).body; + expect(address[`/a/${encodeURIComponent(d)}`].filter).toEqual(cases[2].filter); + expect((await post(host, owner, "/query", [cases[2].filter])).body.map((e: { id: string }) => e.id)).toEqual([next.id]); + c.ws.close(); + }); + + it("omits missing, deleted, expired, held and private events and unsupported browser paths", async () => { + const host = "ad-hidden.bind.ws", owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const c = await WS.connect(host); + const deleted = ev(owner, 1, "deleted"), held = ev(owner, 1, "held"); + const expiring = ev(owner, 1, "expires", [["expiration", String(now() + 60)]]); + const dm = ev(owner, 4, "private", [["p", pk(owner)]]); + const profile = ev(owner, 0, "{}"); + const article = ev(owner, 30023, "gone", [["d", "gone"]]); + for (const e of [deleted, held, expiring, dm, profile, article]) expect((await c.ok(e)).ok).toBe(true); + expect((await c.ok(ev(owner, 5, "", [["e", deleted.id], ["a", `30023:${pk(owner)}:gone`]]))).ok).toBe(true); + await runInDurableObject(env.RELAY.getByName("ad-hidden"), (relay) => { + relay.store.hidden.add(held.id); + relay.sql.exec("UPDATE events SET expires=? WHERE id=?", now() - 1, expiring.id); + }); + 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"])) { + expect((await lookup(host, path)).body, path).toEqual({}); + } + c.ws.close(); + }); + + it("preserves private metadata admission and never opens public pages with authentication", async () => { + const host = "ad-private.bind.ws", owner = generateSecretKey(), outsider = generateSecretKey(); + await rpc(host, owner, "claim"); + const c = await WS.connect(host), note = ev(owner, 1, "members only"); + expect((await c.ok(note)).ok).toBe(true); + await rpc(host, owner, "setpolicy", { reads: "members", directoryPublic: false }); + expect(await lookup(host, "/")).toEqual({ status: 401, body: {} }); + expect(await lookup(host, "/", outsider)).toEqual({ status: 403, body: {} }); + expect((await lookup(host, "/", owner)).body["/"].filter).toMatchObject({ kinds: [39000] }); + for (const signer of [undefined, outsider, owner]) expect((await lookup(host, `/e/${note.id}`, signer)).body).toEqual({}); + await rpc(host, owner, "setpolicy", { reads: "auth" }); + expect((await lookup(host, "/")).status).toBe(401); + expect((await lookup(host, "/", outsider)).body["/"]).toBeDefined(); + await rpc(host, owner, "setpolicy", { reads: "open" }); + expect((await lookup(host, "/")).body["/"]).toBeDefined(); + c.ws.close(); + }); + + it("preserves name lookup and directory semantics independently of path and page discovery", async () => { + const host = "ad-names.bind.ws", owner = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setmember", pk(owner), { name: "alice" }); + const wellKnown = `http://${host}/.well-known/nostr.json`; + const names = { names: { alice: pk(owner) }, relays: { [pk(owner)]: [`wss://${host}`] } }; + expect(await (await SELF.fetch(wellKnown)).json()).toEqual(names); + expect(await (await SELF.fetch(wellKnown + "?name=alice&path=/")).json()).toEqual(names); + await rpc(host, owner, "setpolicy", { directoryPublic: false }); + expect(await (await SELF.fetch(wellKnown)).json()).toEqual({ names: {}, relays: {} }); + expect(await (await SELF.fetch(wellKnown + "?name=alice")).json()).toEqual(names); + const c = await WS.connect(host), note = ev(owner, 1, "public page"); + expect((await c.ok(note)).ok).toBe(true); + await rpc(host, owner, "setpolicy", { features: { names: false } }); + expect((await SELF.fetch(wellKnown + "?name=alice")).status).toBe(404); + expect((await SELF.fetch(wellKnown + "?name=alice&path=/")).status).toBe(404); + expect((await lookup(host, "/")).body["/"]).toBeDefined(); + expect((await lookup(host, `/e/${note.id}`)).body[`/e/${note.id}`]).toBeDefined(); + await rpc(host, owner, "setpolicy", { features: { pages: false, sites: false } }); + expect((await lookup(host, `/e/${note.id}`)).body).toEqual({}); + expect((await lookup(host, "/")).body["/"]).toBeDefined(); + c.ws.close(); + }); + + it("answers explicit discovery before content negotiation with CORS, HEAD and narrow preflight", async () => { + const host = "ad-http.bind.ws", owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const url = endpoint(host, "/"); + const res = await SELF.fetch(url, { headers: { accept: "application/nostr+json" } }); + expect((await res.json>())["/"]).toBeDefined(); + expect(res.headers.get("cache-control")).toContain("no-store"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + for (const method of ["HEAD", "OPTIONS"]) { + const r = await SELF.fetch(url, { method }); + expect(r.status).toBe(200); + expect(await r.text()).toBe(""); + expect(r.headers.get("access-control-allow-headers")).toContain("authorization"); + } + for (const method of ["POST", "PUT", "DELETE"]) expect((await SELF.fetch(url, { method })).status).toBe(405); + const bad = await SELF.fetch(url, { headers: { authorization: "Nostr broken" } }); + expect(bad.status).toBe(401); + expect(await bad.json()).toEqual({}); + }); + + it("rejects malformed, oversized and external path inputs without resolving another URL", async () => { + const host = "ad-paths.bind.ws", owner = generateSecretKey(); + await rpc(host, owner, "claim"); + 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)]) { + expect(await lookup(host, path), path).toEqual({ status: 400, body: {} }); + } + expect((await SELF.fetch(endpoint(host, "/") + "&path=/a/other")).status).toBe(400); + expect((await SELF.fetch(`http://${host}/a/%`)).status).toBe(404); + }); + + it("uses the routed custom relay host and preserves local WebSocket schemes and ports", async () => { + const owner = generateSecretKey(); + await rpc("ad-alias.bind.ws", owner, "claim"); + await env.HOSTS.put("relay.ad-example.test", "ad-alias"); + expect((await lookup("relay.ad-example.test", "/")).body["/"].relays).toEqual(["wss://relay.ad-example.test"]); + await rpc("ad-local.localhost:8787", owner, "claim"); + expect((await lookup("ad-local.localhost:8787", "/")).body["/"].relays).toEqual(["ws://ad-local.localhost:8787"]); + }); + + it("omits unclaimed or expired relays and refuses blocked IP addresses", async () => { + expect((await lookup("ad-unclaimed.bind.ws", "/")).body).toEqual({}); + const host = "ad-blocked.bind.ws", owner = generateSecretKey(); + await rpc(host, owner, "claim"); + expect((await rpc(host, owner, "blockip", "203.0.113.55", "scraper")).status).toBe(200); + expect((await SELF.fetch(endpoint(host, "/"), { headers: { "cf-connecting-ip": "203.0.113.55" } })).status).toBe(403); + await runInDurableObject(env.RELAY.getByName("ad-blocked"), (relay) => { + relay.settings.update({ owner: "", lease: { until: now() - 1, holder: "" } }); + }); + expect((await lookup(host, "/")).body).toEqual({}); + }); +}); diff --git a/test/object/site-addresses.test.ts b/test/object/site-addresses.test.ts new file mode 100644 index 0000000..55dc50c --- /dev/null +++ b/test/object/site-addresses.test.ts @@ -0,0 +1,169 @@ +import { SELF, env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import { aggregate, siteLabel } from "../../src/sites.ts"; +import { upload } from "../helpers/media.ts"; +import { ev, nip98, now, pk, rpc } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +describe("site web addresses", () => { + it("maps the root and browser directory paths to manifest events", async () => { + const name = "site-addresses", host = `${name}.bind.ws`, sk = generateSecretKey(); + await rpc(host, sk, "claim"); + const file = await upload(host, sk, "site address"); + const c = await WS.connect(host); + const site = ev(sk, 15128, "", [["path", "/index.html", file.sha], ["path", "/docs/index.html", file.sha], ["path", "/404.html", file.sha]]); + const named = ev(sk, 35128, "", [["d", "docs"], ["path", "/index.html", file.sha]]); + 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"]]); + expect(await c.ok(site)).toMatchObject({ ok: true }); + expect(await c.ok(named)).toMatchObject({ ok: true }); + expect(await c.ok(snapshot)).toMatchObject({ ok: true }); + await runInDurableObject(env.RELAY.getByName(name), (relay) => relay.syncSites()); + const address = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`; + const root = await SELF.fetch(address + "?path=%2F"); + expect(root.status).toBe(200); + expect(root.headers.get("access-control-allow-origin")).toBe("*"); + expect(root.headers.get("cache-control")).toContain("no-store"); + expect(await root.json()).toEqual({ "/": { filter: { authors: [site.pubkey], kinds: [15128], limit: 1 }, relays: [`wss://${name}.bind.ws`] } }); + expect(await (await SELF.fetch(address + "?path=%2Fdocs")).json()).toEqual({ "/docs": { filter: { authors: [site.pubkey], kinds: [15128], limit: 1 }, relays: [`wss://${name}.bind.ws`] } }); + expect(await (await SELF.fetch(address + "?path=%2Fmissing.html")).json()).toEqual({}); + const namedURL = `http://${siteLabel(named)}.bind.ws/.well-known/nostr.json?path=%2F`; + expect(await (await SELF.fetch(namedURL)).json()).toEqual({ "/": { filter: { authors: [named.pubkey], kinds: [35128], "#d": ["docs"], limit: 1 }, relays: [`wss://${name}.bind.ws`] } }); + const snapshotURL = `http://${siteLabel(snapshot)}.bind.ws/.well-known/nostr.json?path=%2F`; + expect(await (await SELF.fetch(snapshotURL)).json()).toEqual({ "/": { filter: { ids: [snapshot.id], limit: 1 }, relays: [`wss://${name}.bind.ws`] } }); + const head = await SELF.fetch(address + "?path=%2F", { method: "HEAD" }); + expect(head.status).toBe(200); + expect(await head.text()).toBe(""); + expect((await SELF.fetch(address + "?path=%2F", { method: "POST" })).status).toBe(405); + expect((await SELF.fetch(address + "?path=%2F&path=%2Fdocs")).status).toBe(400); + expect((await SELF.fetch(address + "?path=%2F%2e%2e%2Fx")).status).toBe(400); + c.ws.close(); + }); + + it("keeps NIP-05 names and read authentication separate from path discovery", async () => { + const name = "site-address-auth", host = `${name}.bind.ws`, sk = generateSecretKey(); + await rpc(host, sk, "claim"); + const c = await WS.connect(host); + const site = ev(sk, 35128, "", [["d", "docs"], ["path", "/index.html", "ab".repeat(32)]]); + expect((await c.ok(site)).ok).toBe(true); + await rpc(host, sk, "setpolicy", { reads: "members" }); + const address = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`; + expect((await SELF.fetch(address + "?name=nobodyq7")).status).toBe(401); + expect((await SELF.fetch(address + "?path=%2F")).status).toBe(401); + const options = await SELF.fetch(address + "?path=%2F", { method: "OPTIONS" }); + expect(options.status).toBe(204); + expect(options.headers.get("access-control-allow-origin")).toBe("*"); + c.ws.close(); + }); + + it("requires an exact NIP-98 URL and applies the site read rule", async () => { + const name = "site-address-proof", host = `${name}.bind.ws`, owner = generateSecretKey(), outsider = generateSecretKey(); + await rpc(host, owner, "claim"); + const c = await WS.connect(host); + const site = ev(owner, 15128, "", [["path", "/index.html", "ab".repeat(32)]]); + expect((await c.ok(site)).ok).toBe(true); c.ws.close(); + await rpc(host, owner, "setpolicy", { reads: "members" }); + const url = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json?path=%2F`; + const bad = await SELF.fetch(url, { headers: { authorization: await nip98(outsider, url + "x") } }); + expect(bad.status).toBe(401); + const denied = await SELF.fetch(url, { headers: { authorization: await nip98(outsider, url) } }); + expect(denied.status).toBe(403); + const allowed = await SELF.fetch(url, { headers: { authorization: await nip98(owner, url) } }); + expect(allowed.status).toBe(200); + }); + + it("keeps encoded filenames ahead of their decoded spelling and resolves encoded directories", async () => { + const name = "site-address-escaped", host = `${name}.bind.ws`, sk = generateSecretKey(); + await rpc(host, sk, "claim"); + const file = await upload(host, sk, "escaped"), blocked = await upload(host, sk, "blocked escaped"); + const c = await WS.connect(host); + 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]]); + expect((await c.ok(site)).ok).toBe(true); c.ws.close(); + const url = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`; + const encoded = await SELF.fetch(url + "?path=%2Fhello%2520world.html"); + expect(encoded.status).toBe(200); + expect(Object.keys(await encoded.json())).toEqual(["/hello%20world.html"]); + const directory = await SELF.fetch(url + "?path=%2Fdocs%2520space%2F"); + expect(directory.status).toBe(200); + expect(Object.keys(await directory.json())).toEqual(["/docs%20space/"]); + expect(await (await SELF.fetch(`http://${siteLabel(site)}.bind.ws/docs%20space/`)).text()).toBe("escaped"); + expect(await (await SELF.fetch(url + "?path=%2Fonly%2520escaped%2F")).json()).toEqual({}); + expect(Object.keys(await (await SELF.fetch(url + "?path=%2F100%2525.html")).json())).toEqual(["/100%25.html"]); + // The encoded filename wins even when the decoded tag comes first. + await runInDurableObject(env.RELAY.getByName(name), (relay) => { relay.settings.setEvent(blocked.sha, "ban", "moderated"); }); + expect(await (await SELF.fetch(url + "?path=%2Fhello%2520world.html")).json()).toEqual({}); + expect((await SELF.fetch(`http://${siteLabel(site)}.bind.ws/hello%20world.html`)).status).toBe(404); + }); + + it("preserves custom site targets, canonical relay hints and stale-edge isolation", async () => { + const name = "site-address-custom", host = `${name}.bind.ws`, sk = generateSecretKey(); + const custom = "site.ad-custom.test"; + await rpc(host, sk, "claim"); + const file = await upload(host, sk, "custom site"); + const c = await WS.connect(host), site = ev(sk, 15128, "", [["path", "/index.html", file.sha]]); + expect(await c.ok(site)).toMatchObject({ ok: true }); c.ws.close(); + const label = siteLabel(site); + // The edge still has its old relay-only mapping when the local target changes. + await env.HOSTS.put(custom, name); + await runInDurableObject(env.RELAY.getByName(name), (relay) => { + relay.settings.update({ customHosts: [{ host: custom, id: "custom-1", site: label, at: now(), status: "active", sslStatus: "active" }] }); + }); + const url = `http://${custom}/.well-known/nostr.json?path=%2F`; + const expected = { "/": { filter: { kinds: [15128], authors: [pk(sk)], limit: 1 }, relays: [`wss://${host}`] } }; + expect(await (await SELF.fetch(url, { headers: { accept: "application/nostr+json" } })).json()).toEqual(expected); + for (const path of ["/people", "/e/" + site.id, "/.well-known/nostr.json?name=alice"]) expect((await SELF.fetch(`http://${custom}${path}`)).status).toBe(404); + expect((await SELF.fetch(`http://${custom}/`, { headers: { upgrade: "websocket" } })).status).toBe(405); + const localURL = `http://${label}.localhost:8787/.well-known/nostr.json?path=%2F`; + expect(await (await SELF.fetch(localURL)).json()).toEqual({ "/": { ...expected["/"], relays: [`ws://${name}.localhost:8787`] } }); + // A stale site edge mapping must not reopen either a removed site or the relay. + const stale = "stale.ad-custom.test"; + await env.HOSTS.put(stale, JSON.stringify({ name, site: label })); + expect((await SELF.fetch(`http://${stale}/.well-known/nostr.json?path=%2F`)).status).toBe(404); + }); + + it("keeps hosted nostr.json bytes for name lookups without exposing the relay directory", async () => { + const name = "site-address-names", host = `${name}.bind.ws`, sk = generateSecretKey(); + await rpc(host, sk, "claim"); + await rpc(host, sk, "setmember", pk(sk), { name: "relay-owner" }); + const bytes = JSON.stringify({ names: { website: "ab".repeat(32) } }); + const file = await upload(host, sk, bytes); + const c = await WS.connect(host); + const site = ev(sk, 15128, "", [["path", "/index.html", file.sha], ["path", "/.well-known/nostr.json", file.sha]]); + expect(await c.ok(site)).toMatchObject({ ok: true }); c.ws.close(); + const url = `http://${siteLabel(site)}.bind.ws/.well-known/nostr.json`; + for (const query of ["", "?name=website", "?name=relay-owner&path=%2F"]) expect(await (await SELF.fetch(url + query)).text()).toBe(bytes); + const mapping = await (await SELF.fetch(url + "?path=%2F")).json>(); + expect(Object.keys(mapping)).toEqual(["/"]); + expect(JSON.stringify(mapping)).not.toContain("relay-owner"); + }); + + it("hides unavailable manifests and switched-off sites even with a cached host route", async () => { + const name = "site-address-hidden", host = `${name}.bind.ws`, sk = generateSecretKey(); + await rpc(host, sk, "claim"); + const file = await upload(host, sk, "live site"); + const c = await WS.connect(host), site = ev(sk, 15128, "", [["path", "/index.html", file.sha]]); + expect(await c.ok(site)).toMatchObject({ ok: true }); c.ws.close(); + const label = siteLabel(site), url = `http://${label}.bind.ws/.well-known/nostr.json?path=%2F`; + expect((await SELF.fetch(url)).status).toBe(200); + await rpc(host, sk, "setpolicy", { features: { sites: false } }); + expect((await SELF.fetch(url)).status).toBe(404); + await rpc(host, sk, "setpolicy", { features: { sites: true } }); + await runInDurableObject(env.RELAY.getByName(name), (relay) => { relay.store.hidden.add(site.id); }); + expect((await SELF.fetch(url)).status).toBe(404); + await runInDurableObject(env.RELAY.getByName(name), (relay) => { + relay.store.hidden.delete(site.id); + relay.sql.exec("UPDATE events SET expires=? WHERE id=?", now() - 1, site.id); + }); + expect((await SELF.fetch(url)).status).toBe(404); + await runInDurableObject(env.RELAY.getByName(name), (relay) => { + relay.sql.exec("UPDATE events SET expires=0 WHERE id=?", site.id); + relay.settings.update({ owner: "", lease: { holder: "", until: now() - 1 } }); + }); + expect((await SELF.fetch(url)).status).toBe(404); + await runInDurableObject(env.RELAY.getByName(name), (relay) => { + relay.settings.update({ owner: pk(sk), lease: null }); + relay.sql.exec("DELETE FROM events WHERE id=?", site.id); + }); + expect((await SELF.fetch(url)).status).toBe(404); + }); +}); diff --git a/test/object/site-auth.test.ts b/test/object/site-auth.test.ts index c844178..f40b71c 100644 --- a/test/object/site-auth.test.ts +++ b/test/object/site-auth.test.ts @@ -84,12 +84,17 @@ describe("site sign-in at the public door", () => { const page = await SELF.fetch(f.origin + "/", { headers: { cookie: value } }); expect(page.status).toBe(200); expect(await page.text()).toBe("private site bytes"); expect(page.headers.get("cache-control")).toContain("no-store"); + const discoveryURL = f.origin + "/.well-known/nostr.json?path=%2F"; + const discovery = await SELF.fetch(discoveryURL, { headers: { cookie: value } }); + expect(discovery.status).toBe(200); + expect(await discovery.json()).toEqual({ "/": { filter: { kinds: [15128], authors: [pk(f.owner)], limit: 1 }, relays: [`wss://${f.host}`] } }); const altered = value.slice(0, -5) + "zzzzz"; expect((await SELF.fetch(f.origin + "/", { headers: { cookie: altered } })).status).toBe(401); // A cookie for this site does not authenticate at the relay door. expect((await SELF.fetch("https://" + f.host + "/query", { method: "POST", headers: { cookie: value }, body: "[]" })).status).toBe(401); await rpc(f.host, f.owner, "removemember", pk(f.member)); expect((await SELF.fetch(f.origin + "/", { headers: { cookie: value } })).status).toBe(403); + expect((await SELF.fetch(discoveryURL, { headers: { cookie: value } })).status).toBe(403); }); it("rejects cross-origin proofs, expired challenges and malformed exchanges", async () => {