Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/14-http-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ audience: integrator

Every path the worker and a relay answer. Paths are on `https://<name>.bind.ws` unless marked apex. One read rule covers every door that shows events, files, names or presence: where the auth column says "the read rule", a relay whose reads are *anyone* answers without a signature, one whose reads are *signed in* takes any valid signature, and one whose reads are *members* takes a member's. Auth column: "NIP-98" is a signed request (see [Scripts and agents](13-scripts-and-agents.md)); "Blossom" is a kind 24242 token in the Authorization header; "none" is public. JSON errors carry `{ "error": "<prefix>: reason" }`; Blossom and NIP-96 errors also set an `X-Reason` header.

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

Expand All @@ -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=<pathname>` | GET, HEAD; OPTIONS preflight | the read rule for the group; public pages only | NIP-AD path-keyed `{ filter, relays }`, or `{}` without a mapping; [NIP-AD web addresses](23-nip-ad-web-addresses.md) | 200; 400 invalid pathname; 401/403 group read rule; 405 other methods |
| `/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 |
Expand All @@ -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=<pathname>` | GET, HEAD; OPTIONS preflight | the site's read rule | NIP-AD mapping to the live manifest with its hosting relay hint; no file fetch | 200; 204 preflight; 400 invalid pathname; 401/403 read rule; 404 unavailable site; 405 other methods |

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
Expand Down
6 changes: 6 additions & 0 deletions docs/20-nip-5a-static-websites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions docs/23-nip-ad-web-addresses.md
Original file line number Diff line number Diff line change
@@ -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://<host>/.well-known/nostr.json?path=<encoded-pathname>`. 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": ["<owner-pubkey>"],
"#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/<id>` | That exact note or article event ID, including its version |
| Relay `/a/<d>` | Owner's current kind 30023 article with that identifier |
| Relay `/a/<author>/<d>` | 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.
66 changes: 66 additions & 0 deletions src/nipad.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown> | 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<string, unknown> | 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);
}
26 changes: 18 additions & 8 deletions src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<main><h1>Not here</h1><p class="lead">${escapeHTML(why)}</p><footer class="pg"><p><a href="/">${escapeHTML(relay.settings.policy.name || relay.slug)}</a></p></footer></main>`;
return new Response(page("not here", body, PAGE_CSS), { status: 404, headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });
Expand Down
6 changes: 5 additions & 1 deletion src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } }),
Expand Down
Loading