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 () => {