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
40 changes: 40 additions & 0 deletions docs/manual-signer-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,43 @@ With the pasted-key (local) signer configured:
signed events (pubkey + sig) and the public key ever leave the page.
- [ ] Repeat the sweep once for a NIP-46 session: WebSocket frames to the
bunker relay carry only encrypted kind 24133 envelopes.

## 8. First-party relay (`wss://nbread.lol/relay`) — issue #5

The editor prepends the first-party relay to every publish broadcast, so a
normal publish already exercises the write path. These checks confirm the
relay is externally reachable and enforces NIP-42 auth + the claimed-handle
allowlist. Uses [`nak`](https://github.com/fiatjaf/nak) (any NIP-01 CLI works).

- [ ] **NIP-11 reachable**: `nak relay wss://nbread.lol/relay` (or
`curl -H "Accept: application/nostr+json" https://nbread.lol/relay`)
returns the document with `supported_nips` including `42` and
`limitation.restricted_writes: true`.
- [ ] **Publish → relay readback**: publish a post from the editor (any
signer), then read it straight back off the first-party relay by author:
`nak req -k 30023 -a <your-hex-pubkey> wss://nbread.lol/relay` returns
your 30023 event (and EOSE), and the same post is live at
`https://<handle>.nbread.lol/<slug>` — relay and blog agree because they
share one store.
- [ ] **Claimed key writes (NIP-42)**: with your CLAIMED nbread key,
`nak event -k 30023 -c "relay auth test" --sec <nsec> --auth
wss://nbread.lol/relay` — nak answers the `AUTH` challenge, signs the
kind 22242, and the relay returns `OK … true`; the event is then
readable via `nak req`.
- [ ] **Unclaimed key refused**: repeat the previous step with a key that has
NO claimed nbread handle. After AUTH succeeds, the EVENT is rejected with
`OK … false "restricted: writes are limited to claimed nbread.lol
handles"` — nothing is stored.
- [ ] **Unauthenticated write refused**: `nak event -k 30023 --sec <nsec>
wss://nbread.lol/relay` WITHOUT `--auth` → `OK … false
"auth-required: …"` and no post appears.
- [ ] **Wrong kind refused**: `nak event -k 1 -c hi --sec <claimed-nsec>
--auth wss://nbread.lol/relay` → `OK … false "restricted: only kinds
30023, 5, and 0 are accepted"`.
- [ ] **Delete propagates**: delete a post from the editor (kind 5), then
`nak req -k 30023 -a <your-hex-pubkey> wss://nbread.lol/relay` no longer
returns the tombstoned post (but `-k 5` still returns the delete marker).
- [ ] **External client reads an nbread post**: open the post's `naddr`/`nevent`
on a third-party long-form reader (e.g. habla.news) configured to include
`wss://nbread.lol/relay`, and confirm it loads the nbread-hosted 30023 —
reads are open (no auth) to anyone.
9 changes: 9 additions & 0 deletions docs/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ abuse bounds, not politeness.
| `GET /search` | `search:ip` → 30/min (non-empty `q` only) | — | FTS MATCH (sanitized) + join, LIMIT 20 |
| `GET /npub1…` (+ `/rss.xml`, `/atom.xml`, `/:slug`) | `npub:ip` → 60/min *(P7)*; relay mirror sessions additionally: per-pubkey cooldown 300s (Cache API marker) + `npub-mirror:ip` → 30/day + `npub-mirror:global` → 500/day, ≤10 verifications/session | — | ≤100-row post list per view (the P7 limiter closes the unmetered-read gap) |
| `GET /.well-known/nostr.json` | — | `max-age=300` | 1-row indexed read (WAF backstop); blocked/unknown → `{"names":{}}` |
| `GET /relay` (ws upgrade) | `relay:ip` → 30/10min (Worker-side, **fail closed**) | — | denied upgrade never spends a DO request; accepted → one DO request opening a hibernatable ws session (no duration billing). Registered on the outer app **before** `securityHeaders` (a 101 is immutable) |
| `GET /relay` (NIP-11 / info) | — | `max-age=3600` (NIP-11 doc) | Worker-served, **zero DO cost**: `Accept: application/nostr+json` → NIP-11 document (`CORS *`); bare GET → plain-text info page. Per-ws-session budgets live in the DO (`relay:ev:pk` 30/5min, `relay:global:store` 500/day, 256 concurrent conns, 120 msg/min/conn) |
| `GET /admin` | ADMIN_PUBKEY gate (404 otherwise) | — | 1 KV session read + ≤200-row blocked list |
| `POST /admin/block`, `POST /admin/unblock` | gate + `admin:pk` → 30/5min *(P7)* | — | 1 D1 write + 1 KV gen bump |

Expand Down Expand Up @@ -144,6 +146,12 @@ them.** Configure once after Gate B:
document plus a couple of same-origin assets; post images/media load from
external origins — so 6 rps sustained is far above human browsing. Shared
NAT (CGNAT) bursts may occasionally trip it; mitigation lasts only 10s.
**Relay note**: a `wss://nbread.lol/relay` connection counts as **one**
request against this rule — the HTTP UPGRADE handshake. WebSocket frames
after the upgrade are NOT individual zone requests, so the rule does not
bound relay message throughput (the DO's own per-connection 120 msg/min and
the Worker-side `relay:ip` 30/10min upgrade limit do that); it only bounds
the rate of new connections from one IP.
- **Then take action**: **Block**
- **For duration**: 10 seconds (free-plan mitigation timeout)
- **Why 60, and when to tighten**: the WAF is the ONLY control over the
Expand Down Expand Up @@ -196,6 +204,7 @@ editor's slugify never mints such shapes.
| **D1 rows written** | 100,000 | **All limiters fail CLOSED** → 429s on challenge/login/discover-miss/search/npub/mirror; nonce issuance fails → logins stop | This is the deliberate fail-safe posture: the platform read paths (cached blogs, discover hits) keep serving. WAF-block the source; wait for reset |
| **Cache API** | best-effort | All cache layers degrade to uncached (every layer is try/caught) → D1/CPU load rises, correctness unchanged | Watch D1 budgets (above); usually transient |
| **Worker requests** | 100,000/day | Cloudflare serves errors once exceeded | WAF rate rule is the main dial; scanner-path block cuts the noise floor |
| **Durable Object requests** | 100,000/day (free plan, SQLite backend) | The relay stops accepting connections — `wss://nbread.lol/relay` upgrades error; existing ws sessions may drop. **Blogs, editor, `/api/mirror`, and cron are unaffected** — the relay is **additive, never load-bearing** (its store is the shared D1 `events` table, so nothing published is lost) | WAF-block the upgrade source; the Worker-side `relay:ip` 30/10min + the DO's 256-concurrent-conn cap bound the burn. Hibernating connections accrue **no duration billing**, and protocol pings are auto-ponged without waking the object; quota resets daily (UTC) |

Observability is enabled in `wrangler.jsonc`; `wrangler tail` gives live
logs (rate-limit denials log their key via `console.error` on D1 failures
Expand Down
13 changes: 13 additions & 0 deletions migrations/0005_relay.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Migration number: 0005 relay
-- First-party relay (#5) support.
--
-- The relay REQ engine's dominant query is authors (+ kinds) ordered by
-- created_at DESC. The existing idx_events_feed(kind, deleted, created_at)
-- cannot serve that ordering for a pubkey-scoped scan, so add a dedicated
-- author+time index.
CREATE INDEX idx_events_author_time ON events(pubkey, created_at DESC);

-- The relay endpoint lives at the apex path wss://nbread.lol/relay, but
-- reserve the handle anyway so no blog ever claims relay.nbread.lol.
INSERT OR IGNORE INTO reserved_handles (handle) VALUES
('relay');
41 changes: 41 additions & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,35 @@ check_post() {
fi
}

# check_accept <desc> <expected-code> <host> <path> <accept>
# Like check(), but sends an Accept header — used for the NIP-11 relay
# document (application/nostr+json). Leaves the response body in
# /tmp/smoke_body and headers in /tmp/smoke_headers for the check_body_contains
# / check_header_contains assertions that follow.
check_accept() {
local desc="$1" expected="$2" host_header="$3" path="$4" accept="$5"
local args=(-s -o /tmp/smoke_body -D /tmp/smoke_headers -w '%{http_code}' \
--max-time 15 -H "Accept: $accept")
local url
if [[ "$TARGET" == "local" ]]; then
url="$BASE$path"
if [[ -n "$host_header" ]]; then
args+=(-H "X-Forwarded-Host: $host_header")
fi
else
url="https://$host_header$path"
fi
local code
code=$(curl "${args[@]}" "$url" || echo "000")
if [[ "$code" == "$expected" ]]; then
echo "PASS [$code] $desc"
PASS=$((PASS + 1))
else
echo "FAIL [$code != $expected] $desc"
FAIL=$((FAIL + 1))
fi
}

# --- P0: hello checks ---------------------------------------------------------
check "apex / responds 200" 200 "$MAIN_HOST" "/"
check_body_contains "apex / mentions nbread.lol" "nbread.lol"
Expand Down Expand Up @@ -232,6 +261,18 @@ fi
check "admin surface hidden (disabled or anonymous)" 404 "$MAIN_HOST" "/admin"
check_post "admin actions hidden too" 404 "$MAIN_HOST" "/admin/block" '{"target":"alice"}'

# --- P8: first-party relay (#5) --------------------------------------------------
# A bare GET is the plain-text info page (Worker-served, no DO cost); the
# NIP-11 document comes back only for Accept: application/nostr+json. The ws
# upgrade itself is exercised by the integration suite (curl can't drive a
# NIP-01 session) and by the manual checklist, not here.
check "relay info page responds 200" 200 "$MAIN_HOST" "/relay"
check_body_contains "relay info page names the ws endpoint" "wss://$MAIN_HOST/relay"
check_accept "relay serves the NIP-11 document" 200 "$MAIN_HOST" "/relay" "application/nostr+json"
check_body_contains "relay NIP-11 advertises NIP-42" '"supported_nips":\[1,9,11,42\]'
check_body_contains "relay NIP-11 restricts writes" '"restricted_writes":true'
check_header_contains "relay NIP-11 sends CORS *" "access-control-allow-origin: \*"

# --- P5 MANUAL check (documented, not automated): full write→render loop ---------
# The end-to-end publish flow needs a REAL NIP-07 extension signing in a real
# browser, which curl cannot drive. Once per release, verify by hand:
Expand Down
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { authRoutes } from "./routes/auth";
import { dashboardRoutes } from "./routes/dashboard";
import { adminRoutes } from "./routes/admin";
import { wellknownRoutes } from "./routes/wellknown";
import { relayEndpoint } from "./relay/http";
import { MainNotFound } from "./views/main/not-found";

/**
Expand Down Expand Up @@ -63,6 +64,13 @@ blogApp.route("/", tenantRoutes);
// --- Outer app ----------------------------------------------------------------
export const app = new Hono<AppEnv>();

// First-party relay endpoint BEFORE the middleware stack: a successful
// WebSocket upgrade is an immutable 101 response (securityHeaders'
// headers.set() would throw on it), and guard/tenant/csrf/session must never
// run per-upgrade. The handler does its own host self-check and sets its own
// headers (src/relay/http.ts).
app.all("/relay", relayEndpoint);

// Security headers FIRST so they wrap every outcome — including guard 404s
// (unknown hosts), tenant 404s (unclaimed/blocked subdomains), and cache
// hits served inside the blog sub-app.
Expand Down
9 changes: 8 additions & 1 deletion src/cron/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { fetchEvents } from "../nostr/relay";
import { bumpGen, mirrorEvent } from "../services/mirror";
import { storedEventIds } from "../services/events";
import type { NostrEvent } from "../nostr/event";
import { isSelfRelayHost } from "../relay/url";

/** Max NEW events verified+mirrored per user per cron run (contract: ~5). */
export const REFRESH_VERIFY_CAP = 5;
Expand Down Expand Up @@ -166,7 +167,13 @@ async function refreshUser(
// their own relays would otherwise never be mirrored by cron. Merge their
// list ahead of the service defaults (deduped).
const configured = readBlogSettings(user.settings).relays;
const relays = [...new Set([...configured, ...baseRelays])];
// Filter out our own first-party relay AFTER the merge (users may paste
// wss://nbread.lol/relay into their settings): a Worker-to-own-zone ws
// subrequest won't reliably re-enter this Worker, and the relay shares the
// same D1 events store anyway — reading ourselves is a no-op at best.
const relays = [...new Set([...configured, ...baseRelays])].filter(
(url) => !isSelfRelayHost(url, env),
);
const since = readSince(user.settings);
const { events: collected, windowClosed } = await collectBacklog(
relays,
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { app } from "./app";
import { runRefresh } from "./cron/refresh";
import { sweepRateLimits } from "./services/ratelimit";

// First-party relay Durable Object (wrangler.jsonc durable_objects binding
// RELAY_DO + migrations v1 new_sqlite_classes — the free-plan variant).
export { RelayDO } from "./relay/do";

export default {
fetch: app.fetch,

Expand Down
Loading
Loading