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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Nostrbook
# nbread.lol

Nostr-native blogging platform. Your posts are [NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md)
`kind 30023` events signed by **your** key — Nostrbook mirrors them into a fast
edge database and renders them beautifully at `you.nostrbook.net`.
`kind 30023` events signed by **your** key — nbread.lol mirrors them into a fast
edge database and renders them beautifully at `you.nbread.lol`.

- **Own your words**: every post is a signed Nostr event; Nostrbook is just a
- **Own your words**: every post is a signed Nostr event; nbread.lol is just a
renderer. Take your key elsewhere any time and your blog comes with you.
- **No accounts, no passwords**: log in with a NIP-07 browser extension.
- **Fast + minimal**: one Cloudflare Worker, server-side rendering, no
Expand Down Expand Up @@ -37,5 +37,5 @@ Phase-by-phase build plan: see [docs/phases/](docs/phases/).

Copyright (C) 2026 sovITxyz <git@sovit.xyz>

[AGPL-3.0-only](LICENSE). If you run a modified Nostrbook as a service, you
[AGPL-3.0-only](LICENSE). If you run a modified nbread.lol as a service, you
must offer its source to your users.
46 changes: 23 additions & 23 deletions docs/ops.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Nostrbook operations (P7)
# nbread.lol operations (P7)

Hardening + launch reference: security headers, the full per-endpoint
rate-limit/cache inventory, WAF dashboard settings, free-tier incident notes,
Expand All @@ -13,8 +13,8 @@ every Worker response is stamped — guard 404s (unknown/spoofed hosts), tenant

| Class | Applies to | CSP | Extra |
| ----- | ---------- | --- | ----- |
| **Blog** | `<handle>.nostrbook.net` (all paths) AND apex `/npub1…` views | `default-src 'none'; img-src * data:; style-src 'self' 'unsafe-inline'; media-src *; base-uri 'none'; form-action 'none'` | no XFO (embeddable **by design** — see notes) |
| **Apex** | everything else on `nostrbook.net` (+ unknown-host 404s) | `default-src 'none'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src * data:; media-src *; connect-src 'self' wss:; frame-src https://challenges.cloudflare.com; form-action 'self'; base-uri 'none'; frame-ancestors 'none'` | `X-Frame-Options: DENY` |
| **Blog** | `<handle>.nbread.lol` (all paths) AND apex `/npub1…` views | `default-src 'none'; img-src * data:; style-src 'self' 'unsafe-inline'; media-src *; base-uri 'none'; form-action 'none'` | no XFO (embeddable **by design** — see notes) |
| **Apex** | everything else on `nbread.lol` (+ unknown-host 404s) | `default-src 'none'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src * data:; media-src *; connect-src 'self' wss:; frame-src https://challenges.cloudflare.com; form-action 'self'; base-uri 'none'; frame-ancestors 'none'` | `X-Frame-Options: DENY` |

Both classes always send `X-Content-Type-Options: nosniff` and
`Referrer-Policy: strict-origin-when-cross-origin`.
Expand Down Expand Up @@ -63,7 +63,7 @@ closed** (a D1 error denies). Denied requests still count. Sessions are
permissionless (anyone with a keypair can mint one), so per-pubkey limits are
abuse bounds, not politeness.

### Apex (nostrbook.net)
### Apex (nbread.lol)

| Endpoint | Limiter (key → max/window) | Cache | Per-request cost / bound |
| -------- | -------------------------- | ----- | ------------------------ |
Expand All @@ -89,7 +89,7 @@ abuse bounds, not politeness.
CSRF (Origin / Sec-Fetch-Site same-origin proof) covers **every** unsafe
method on the apex, `/admin` and `/api` included.

### Blog subdomains (`<handle>.nostrbook.net`)
### Blog subdomains (`<handle>.nbread.lol`)

| Endpoint | Limiter | Cache | Per-request cost |
| -------- | ------- | ----- | ---------------- |
Expand All @@ -114,7 +114,7 @@ per run.
Nothing else writes KV. P7 added **no new KV write classes** — admin bumps
are ordinary gen bumps.

## 3. WAF setup (Cloudflare dashboard, zone `nostrbook.net`)
## 3. WAF setup (Cloudflare dashboard, zone `nbread.lol`)

Application-level limits above bound *single-source* abuse; the WAF is the
*distributed/volumetric* backstop (and keeps scanner noise off the Worker
Expand All @@ -129,7 +129,7 @@ them.** Configure once after Gate B:

- **Rule name**: `global-per-ip-throttle`
- **If incoming requests match**: preferred expression
`(http.host eq "nostrbook.net") or (http.host wildcard "*.nostrbook.net")`.
`(http.host eq "nbread.lol") or (http.host wildcard "*.nbread.lol")`.
**Free-plan caveat**: rate-limiting rule expressions restrict the field set
(`http.host` and the `wildcard` operator are plan-gated on some accounts).
If the dashboard rejects it, use the guaranteed-configurable fallback: the
Expand Down Expand Up @@ -242,74 +242,74 @@ on the release commit, and `bash scripts/smoke.sh local` green.
### Secrets & bindings checklist (once per account)

```sh
wrangler d1 create nostrbook # paste database_id into wrangler.jsonc
wrangler d1 create nbread # paste database_id into wrangler.jsonc
wrangler kv namespace create KV # paste id into wrangler.jsonc
wrangler d1 migrations apply nostrbook --remote
wrangler d1 migrations apply nbread --remote

wrangler secret put TURNSTILE_SECRET_KEY # from the Turnstile widget (see below)
wrangler secret put ADMIN_PUBKEY # OPTIONAL — omit to launch with /admin disabled
```

- Create the Turnstile widget (dashboard → Turnstile) for `nostrbook.net`
- Create the Turnstile widget (dashboard → Turnstile) for `nbread.lol`
**and add the workers.dev preview hostname** (e.g.
`nostrbook.<account>.workers.dev`) to the widget's hostnames so Gate A can
`nbread.<account>.workers.dev`) to the widget's hostnames so Gate A can
exercise the claim flow. Put the site key in `wrangler.jsonc`
`vars.TURNSTILE_SITE_KEY`.
- `vars.ENVIRONMENT` stays `"production"` in the committed config (the
dev-only X-Forwarded-Host override must never ship enabled).

### Gate A — workers.dev preview

The zone `nostrbook.net` must already exist on the account (add it per
The zone `nbread.lol` must already exist on the account (add it per
`docs/setup.md` §1 — routes can attach while DNS still points elsewhere;
user traffic is unaffected because no DNS records exist yet).

```sh
# Deploy with MAIN_HOST overridden to the preview host so the host guard
# treats workers.dev as the apex (the committed var stays nostrbook.net):
npx wrangler deploy --var MAIN_HOST:nostrbook.<account>.workers.dev
# treats workers.dev as the apex (the committed var stays nbread.lol):
npx wrangler deploy --var MAIN_HOST:nbread.<account>.workers.dev

# Smoke vs the preview URL (subdomain checks auto-skip on workers.dev):
bash scripts/smoke.sh https://nostrbook.<account>.workers.dev
bash scripts/smoke.sh https://nbread.<account>.workers.dev
```

Gate A passes when: CI is green, preview smoke is green, and a manual
login → claim → publish loop works on the preview (needs a NIP-07 extension;
see the manual-check notes at the bottom of `scripts/smoke.sh`).

### Gate B — nostrbook.net live
### Gate B — nbread.lol live

1. **DNS** (per `docs/setup.md` §2): apex `A @ 192.0.2.1` **Proxied** +
wildcard `CNAME * nostrbook.net` **Proxied**. SSL/TLS **Full (strict)**;
confirm Universal SSL covers `nostrbook.net` + `*.nostrbook.net`.
wildcard `CNAME * nbread.lol` **Proxied**. SSL/TLS **Full (strict)**;
confirm Universal SSL covers `nbread.lol` + `*.nbread.lol`.
Then, under SSL/TLS → **Edge Certificates**: enable **Always Use HTTPS**
and **HSTS** with `max-age` ≥ 6 months (15552000). Sessions are Secure
host-only cookies on the apex; without zone HSTS a first-visit `http://`
navigation is interceptable before the 301. Hold off
`includeSubDomains`/preload until tenant subdomains are confirmed stable
on TLS (a preloaded broken wildcard is unrecoverable for months).
2. **Deploy the committed config** (restores `MAIN_HOST=nostrbook.net`):
2. **Deploy the committed config** (restores `MAIN_HOST=nbread.lol`):

```sh
npx wrangler deploy
```

3. Verify both routes (`nostrbook.net/*`, `*.nostrbook.net/*`) appear under
3. Verify both routes (`nbread.lol/*`, `*.nbread.lol/*`) appear under
the zone's Workers Routes page and the cron shows under the Worker's
Triggers tab.
4. **Smoke vs prod** (includes subdomain + header checks):

```sh
bash scripts/smoke.sh https://nostrbook.net
bash scripts/smoke.sh https://nbread.lol
```

5. **Observe the cron**: `npx wrangler tail --format=pretty` across one
15-minute boundary; confirm a `scheduled` execution logs.
6. **Acceptance**: claim a test blog with a real NIP-07 key (login → claim →
publish via the editor, or publish a NIP-23 post from any Nostr client
and wait ≤15 min for the cron) and confirm
`https://<handle>.nostrbook.net/` renders the relay content, RSS
validates, and `https://nostrbook.net/.well-known/nostr.json?name=<handle>`
`https://<handle>.nbread.lol/` renders the relay content, RSS
validates, and `https://nbread.lol/.well-known/nostr.json?name=<handle>`
answers.
7. **WAF**: configure §3 (rate rule + scanner-path block). The preferred
expressions use fields/functions whose free-plan availability varies by
Expand Down
6 changes: 3 additions & 3 deletions docs/phases/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test/{unit,integration}/**.spec.ts test/fixtures/ scripts/{gen-fixtures.ts,sm
docs/phases/P0.md..P7.md # these briefs, copied from this plan
```

**Bindings** (`wrangler.jsonc`): D1 `DB`; KV `KV`; vars `MAIN_HOST=nostrbook.net`, `ENVIRONMENT`, `TURNSTILE_SITE_KEY`, `RELAYS` (comma list: wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band); secrets `TURNSTILE_SECRET_KEY`; cron `*/15 * * * *`; routes `nostrbook.net/*` + `*.nostrbook.net/*` zone `nostrbook.net`; assets dir `public/` binding `ASSETS`.
**Bindings** (`wrangler.jsonc`): D1 `DB`; KV `KV`; vars `MAIN_HOST=nbread.lol`, `ENVIRONMENT`, `TURNSTILE_SITE_KEY`, `RELAYS` (comma list: wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band,wss://relay.primal.net,wss://nostr.wine,wss://purplepag.es,wss://relay.snort.social,wss://nostr.mom); secrets `TURNSTILE_SECRET_KEY`; cron `*/15 * * * *`; routes `nbread.lol/*` + `*.nbread.lol/*` zone `nbread.lol`; assets dir `public/` binding `ASSETS`.

**D1 schema** (migrations/0001_init.sql):
```sql
Expand Down Expand Up @@ -69,7 +69,7 @@ renderPost(md: string): string // markdown-it + strict sanitizer; NO raw

**Fixtures** (`test/fixtures/`, generated once by `scripts/gen-fixtures.ts` with nostr-tools, committed): 3 test keypairs (alice, bob, mallory), signed kind 0 + kind 30023 events (valid set incl. markdown torture + XSS payloads in content), tampered variants (bad sig / bad id / wrong pubkey), a kind 5 delete, stale-vs-newer replaceable pairs. All tests use these — never generate keys at test time.

**Testing rules (every phase)**: implementer ships unit tests with the code; routes get `SELF.fetch` integration tests with Host overrides (`nostrbook.net`, `alice.nostrbook.net`, `unknown.nostrbook.net`, `nostrbook.net.evil.com`); rendered HTML/XML gets snapshot tests; `scripts/smoke.sh <base-url>` grows each phase and must pass; CI = typecheck + vitest.
**Testing rules (every phase)**: implementer ships unit tests with the code; routes get `SELF.fetch` integration tests with Host overrides (`nbread.lol`, `alice.nbread.lol`, `unknown.nbread.lol`, `nbread.lol.evil.com`); rendered HTML/XML gets snapshot tests; `scripts/smoke.sh <base-url>` grows each phase and must pass; CI = typecheck + vitest.


## Addendum (P0, orchestrator-approved)
Expand Down Expand Up @@ -112,7 +112,7 @@ Ratified in full: the `delete_horizons` schema change (migration 0004), the sett
- **Protective leading newline in the editor content and dashboard CSS textareas** (`{"\n" + value}`): the HTML parser eats exactly one newline after `<textarea>`, so content/CSS that itself starts with a newline round-trips unchanged instead of silently losing a leading `\n` (which for a post would mint a different event id on republish).

## Addendum (P6 review fixes — orchestrator-RATIFIED 2026-07-12)
- **`/discover` is really cached and rate limited (was neither)**: a Worker-GENERATED response is never stored in Cloudflare's edge cache merely by carrying `s-maxage` — the original P6 implementation therefore ran the joined D1 feed query on EVERY request, and `?page=50` costs ~1,000 rows_read (OFFSET 980 + join probes), letting a single unauthenticated client burn the platform-wide 5M rows_read/day D1 budget in minutes. Two layers now protect it: (1) a **Cache API entry per page** — key `https://cache.internal/discover?page=<clamped 1..50>` (a SECOND key namespace beside the per-tenant `<host><path>?g=<gen>` contract keys, like the P3 npub-cooldown markers), TTL `DISCOVER_CACHE_SECONDS = 300`, keyed on the CLAMPED page only so cache-buster query params cannot force a miss; hit/miss exposed via `X-Nostrbook-Cache`; only 200s are cached; (2) a **per-IP rate limit** `discover:ip:<ip>` at `DISCOVER_RATE_MAX = 60`/60s via the existing D1 `rate_limits` (zero KV writes), checked ONLY on cache misses, denying with a friendly 429 page (never 5xx). Cross-tenant feed staleness is bounded by the 300s TTL (there is deliberately no gen-bump invalidation — a cross-tenant page cannot key on one pubkey's gen).
- **`/discover` is really cached and rate limited (was neither)**: a Worker-GENERATED response is never stored in Cloudflare's edge cache merely by carrying `s-maxage` — the original P6 implementation therefore ran the joined D1 feed query on EVERY request, and `?page=50` costs ~1,000 rows_read (OFFSET 980 + join probes), letting a single unauthenticated client burn the platform-wide 5M rows_read/day D1 budget in minutes. Two layers now protect it: (1) a **Cache API entry per page** — key `https://cache.internal/discover?page=<clamped 1..50>` (a SECOND key namespace beside the per-tenant `<host><path>?g=<gen>` contract keys, like the P3 npub-cooldown markers), TTL `DISCOVER_CACHE_SECONDS = 300`, keyed on the CLAMPED page only so cache-buster query params cannot force a miss; hit/miss exposed via `X-Nbread-Cache`; only 200s are cached; (2) a **per-IP rate limit** `discover:ip:<ip>` at `DISCOVER_RATE_MAX = 60`/60s via the existing D1 `rate_limits` (zero KV writes), checked ONLY on cache misses, denying with a friendly 429 page (never 5xx). Cross-tenant feed staleness is bounded by the 300s TTL (there is deliberately no gen-bump invalidation — a cross-tenant page cannot key on one pubkey's gen).
- **Slim feed projection (public-path CPU/IO)**: `listRecentClaimedPosts` and `searchPosts` no longer `SELECT e.*` (which hauled content+rendered+raw — up to ~100KiB/row — through D1 per request); they share `FEED_SELECT_COLUMNS` (id, pubkey, kind, d_tag, created_at, tags, handle) with `content` truncated to `FEED_CONTENT_PREFIX_CHARS = 2048` chars, enough for postMeta's first-heading title fallback. Behavior change only for pathological posts: a TITLE-TAG-LESS post whose first markdown heading sits beyond 2,048 chars lists as "Untitled" in discover/search (its own blog pages still parse full content).
- **`searchPosts(env, query, limit?) → Promise<FeedRow[] | null>`** (not in the key-interfaces list; recorded for visibility, supersedes the implementer-report note): `FeedRow` is the slim projection above plus the author's claimed `handle` (needed for cross-tenant URLs; the users JOIN makes claimed+non-blocked scoping structural). **`null` means a real backend failure** (D1 outage/schema drift — hostile input can never reach the catch, the sanitizer's output shape is unit-proven valid MATCH) and `GET /search` renders a distinct 503 "temporarily unavailable" page instead of silently masquerading outages as empty result sets. `[]` still means "no results" (including nothing-searchable queries). The P6 brief's suggested name `search` was not adopted; the pre-existing `searchPosts` stub name/parameter list was kept.
- **P6 CSS scoped to P6 surfaces**: the new `.post-item`/`.post-summary`/`.post-date`/`.empty` rules are scoped under `.feed-list`/`.discover`/`.search` so tenant blog pages and the dashboard (which reuse those class names) keep the exact P5 base styles that per-user theme CSS composes against.
Expand Down
2 changes: 1 addition & 1 deletion docs/phases/P0.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
### P0 — Scaffold (S)
**Deliverables**: repo init at `/home/meshgod/SovITservices/nostrbook` (AGPL-3.0 LICENSE, README with project description), full repo layout above with stubs, `wrangler.jsonc` per contract, `migrations/0001_init.sql` per contract, Hono skeleton with guard + tenant middleware (main vs subdomain vs unknown-host 404), `scripts/gen-fixtures.ts` + committed fixtures, `scripts/smoke.sh` (hello checks), GitHub Actions CI (typecheck, vitest, `wrangler deploy --dry-run`), `docs/phases/*.md` copied from this plan, public GitHub repo `nostrbook` created + pushed (after security audit).
**Deliverables**: repo init at `/home/meshgod/SovITservices/nbread` (AGPL-3.0 LICENSE, README with project description), full repo layout above with stubs, `wrangler.jsonc` per contract, `migrations/0001_init.sql` per contract, Hono skeleton with guard + tenant middleware (main vs subdomain vs unknown-host 404), `scripts/gen-fixtures.ts` + committed fixtures, `scripts/smoke.sh` (hello checks), GitHub Actions CI (typecheck, vitest, `wrangler deploy --dry-run`), `docs/phases/*.md` copied from this plan, public GitHub repo `nbread` created + pushed (after security audit).
**Tests**: tenant middleware unit tests (all 4 host classes); migration applies to local D1; fixture validity self-check.
**Accept**: CI green; `wrangler dev` serves distinct responses on apex vs subdomain; repo public on GitHub.

4 changes: 2 additions & 2 deletions docs/phases/P3.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
### P3 — Ingestion + public blogs live (M)
**Depends**: P1, P2.
**Deliverables**: `services/mirror.ts` per contract (verify → replaceable upsert → deleted handling via kind 5 → FTS maintenance → gen bump; skip verify for event ids already stored), `services/profiles.ts` (kind 0 → profiles row), `cron/refresh.ts` (for each claimed user: fetchEvents kinds 0+30023 since last, mirror each — **cap ~5 new-event verifications per user per run** and resume next tick, keeping schnorr CPU under the free 10ms budget), cache middleware per contract, wire tenant routes to D1 (handle.nostrbook.net + `nostrbook.net/npub1…` on-demand fetch+mirror for unclaimed — **limit to newest 10 events per request**, backfill on later visits via `ctx.waitUntil`).
**Tests**: integration — seed user+fixtures via mirror, `SELF.fetch('https://alice.nostrbook.net/')` lists posts, post page renders, RSS valid; stale event returns `'stale'` and never overwrites; delete hides post; `SELF.scheduled()` mirrors from mock relay; cache: second fetch hits cache, gen bump invalidates.
**Deliverables**: `services/mirror.ts` per contract (verify → replaceable upsert → deleted handling via kind 5 → FTS maintenance → gen bump; skip verify for event ids already stored), `services/profiles.ts` (kind 0 → profiles row), `cron/refresh.ts` (for each claimed user: fetchEvents kinds 0+30023 since last, mirror each — **cap ~5 new-event verifications per user per run** and resume next tick, keeping schnorr CPU under the free 10ms budget), cache middleware per contract, wire tenant routes to D1 (handle.nbread.lol + `nbread.lol/npub1…` on-demand fetch+mirror for unclaimed — **limit to newest 10 events per request**, backfill on later visits via `ctx.waitUntil`).
**Tests**: integration — seed user+fixtures via mirror, `SELF.fetch('https://alice.nbread.lol/')` lists posts, post page renders, RSS valid; stale event returns `'stale'` and never overwrites; delete hides post; `SELF.scheduled()` mirrors from mock relay; cache: second fetch hits cache, gen bump invalidates.
**Accept**: full blog end-to-end from fixtures; replaceable invariants proven in D1.


Expand Down
Loading
Loading