joerglohrerde/docs/superpowers/specs/2026-04-15-nostr-page-desig...

471 lines
20 KiB
Markdown
Raw Normal View History

# Nostr-Page auf Basis von Events — Design-Spec
**Datum:** 2026-04-15
**Status:** Entwurf, ausstehende User-Freigabe
**Scope:** Ablösung der Hugo-Seite `joerg-lohrer.de` durch eine SvelteKit-SPA, die Blog-Posts live aus Nostr-Events rendert. Diese Spec beschreibt **nur die SPA und den Event-/URL-Kontrakt**. Publish-Pipeline (Markdown → Event → Relays + Assets-Upload) ist separate Spec.
---
## 1. Gesamtarchitektur
```
Browser JS-loser Client / Bot
│ │
▼ ▼
┌─────────────────────────────┐ ┌───────────────────────────┐
│ All-Inkl (statisches Hosting)│ │ All-Inkl liefert │
│ • index.html (SPA-Shell) │ │ index.html (leere Shell, │
│ • _app/*.js, *.css │ │ kein Post-Inhalt, keine │
│ • .htaccess (SPA-Fallback) │ │ OG-Tags für Posts) │
│ • images/ bzw. Permalink- │ └───────────────────────────┘
│ Pfade für Altbilder │
└──────────────┬──────────────┘
┌─────────────────────────────────────┐
│ Public Nostr-Relays (wss://) │
│ damus.io, nos.lol, nostr.wine, │
│ relay.nostr.band … │
│ (später erweiterbar um eigene) │
└─────────────────────────────────────┘
Publish-Flow (separate Spec, nur Artefakte hier relevant):
Markdown → signiertes kind:30023 Event → Public-Relays
Bilder (Altbestand) → All-Inkl unter Post-Permalink-Pfad
Bilder (neu) → Blossom-Server (Multi-Upload, single URL im Markdown)
```
### Kernprinzipien
- **All-Inkl hostet nur eine statische SPA-Shell** plus Assets: Altbilder (unter Post-Permalink-Pfaden), Site-Icons, `robots.txt`. Kein Post-Body, keine Stubs, kein Backend.
- **Posts existieren als signierte NIP-23-Events auf mehreren Public-Relays.** Die SPA holt sie zur Laufzeit.
- **URL-Struktur bleibt kompatibel zur bestehenden Hugo-Seite.** Backlinks brechen nicht.
- **Minimale eigene Infrastruktur jetzt** (keine eigene Relay-Instanz, kein eigener Blossom-Server). Alles nachrüstbar ohne Bruch.
- **Bewusst akzeptierte Kosten:** kein SEO, keine Social-Previews in Phase 1. Siehe Risiken und Evolutionspfad.
### Kostenübersicht Phase 1
- All-Inkl: unverändert (vorhandener Tarif).
- Public-Relays: 0 €.
- Public-Blossom-Server (nur Neu-Bilder ab Phase 2): 0 €.
- Domain `joerg-lohrer.de`: unverändert.
- **Zusatzkosten: keine.**
---
## 2. URL-Struktur, Routing, Event-Kontrakt
### URL-Schema (kompatibel zur bestehenden Hugo-Struktur)
| URL | Inhalt |
|---|---|
| `/` | SPA-Shell. SPA rendert Startseite (Profilkachel + Post-Liste). |
| `/YYYY/MM/DD/<dtag>.html/` | SPA-Shell (via `.htaccess`-Fallback). SPA-Router extrahiert `<dtag>` und lädt Event. |
| `/archives/` | SPA-Shell, SPA rendert chronologische Liste. |
| `/tag/<name>/` | SPA-Shell, SPA rendert Tag-Filter. |
| `/impressum/` | Statisches HTML (rechtlicher Content, liegt wirklich auf Server). |
| `/YYYY/MM/DD/<dtag>.html/<bildname>` | echte Bilddateien der 18 Altposts, liegen unter dem jeweiligen Post-Permalink. |
| `/favicon.ico`, `/logo.png`, `/robots.txt` | globale Site-Assets. |
**Datum in der URL** dient nur der Backlink-Kompatibilität. Die SPA benötigt zur Event-Abfrage nur den `dtag`; sie fragt Relays mit `kinds:[30023], authors:[<pubkey>], #d:[<dtag>]`. Das Datum ist URL-Dekoration.
### `.htaccess`
```apache
RewriteEngine On
# HTTPS forcieren
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Existierende Datei oder Verzeichnis? Direkt ausliefern (Bilder, _app/*, favicon etc.).
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alles andere → SPA-Fallback.
RewriteRule ^ /index.html [L]
```
### Event-Kontrakt (NIP-23, `kind:30023`)
Normatives Schema; Publish-Spec muss es einhalten, SPA verlässt sich darauf.
**Pflicht-Tags pro Event:**
- `["d", "<slug>"]` — URL-Slug. Stabiler Identifier über Edits hinweg (replaceable-Semantik).
- `["title", "<post title>"]`
- `["published_at", "<unix-timestamp>"]` — ursprüngliches Veröffentlichungsdatum.
**Empfohlene Tags:**
- `["summary", "<kurzbeschreibung>"]` — genutzt in Listen-Previews.
- `["image", "<url>"]` — Vorschaubild.
- `["t", "<tag>"]` — mehrfach erlaubt, Kategorien/Tags.
**Content:** Markdown. Bilder als Markdown-Syntax mit absoluten URLs.
**Bild-URL-Policy pro Post-Ära:**
- Migrations-Posts (bestehende 18 Posts, 20132024): Bild-URLs auf `joerg-lohrer.de` unter Post-Permalink-Pfad, z. B. `https://joerg-lohrer.de/2023/03/23/gleichnis-vom-saemann.html/bild1.jpeg`. Bilder liegen tatsächlich dort.
- Neue Posts (ab Phase 2): Bild-URLs auf Blossom-Server, z. B. `https://blossom.primal.net/abc123…def.jpeg`. Upload zu 23 Blossom-Servern parallel (BUD-03-Pattern), Markdown referenziert jeweils nur eine URL.
Die SPA unterscheidet die beiden Eras nicht — sie rendert Markdown, der Browser lädt die absolute URL, wo sie auch liegt.
### SPA-Routing
SvelteKit mit `adapter-static`, `ssr: false`, Fallback-Page `index.html`. Routen:
- `/` → Home
- `/[year]/[month]/[day]/[dtag].html/` → PostView (nur `dtag` genutzt)
- `/archives/` → Archives
- `/tag/[name]/` → TagView
- `/impressum/` → eigener Impressum-Pfad (oder statisch außerhalb der SPA)
**Hinweis zum `.html`-Suffix im Routing:** SvelteKit unterstützt statische Dateiendungen in dynamischen Segmenten nicht direkt. Lösung: Entweder den `.html`-Suffix im Parameter-Wert mitbehandeln (`[dtag]` matched den String inklusive `.html`, davon wird beim Auslesen `.html` abgeschnitten) oder den Pfad als Catch-All-Route `[...slug]` aufnehmen und die Teile im Load-Handler selbst parsen. Details in der Implementation.
### Relay-Konfiguration
Fest im Bundle hinterlegte Default-Liste (Konfig-Datei, nicht hartcodiert):
```ts
// src/lib/nostr/config.ts
export const READ_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://nostr.wine',
]
// TODO bei Implementierung: npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9
// in hex decodieren (nip19.decode) und hier eintragen.
export const AUTHOR_PUBKEY_HEX = '<hex wird bei Implementierung aus npub abgeleitet>'
```
Später erweiterbar um eigenen Relay per Konfig-Änderung, kein Code-Umbau.
---
## 3. SPA-Komponenten & Datenfluss
### Stack
- **Framework:** SvelteKit, Adapter `adapter-static`, `ssr: false`, Prerender-Disabled.
- **Nostr-SDK:** `applesauce-relay`, `applesauce-loaders`, `applesauce-signers` (höherstufig, RxJS-basiert, tree-shakable).
- **Markdown-Rendering:** `marked` + `DOMPurify` + `highlight.js` (35 Sprachen registriert).
- **Signing:** NIP-07 (Browser-Extension wie Alby oder nos2x).
### Dateistruktur
```
src/
├── app.html # SPA-Shell <head>-Defaults
├── lib/
│ ├── nostr/
│ │ ├── config.ts # Relays, Pubkey
│ │ ├── pool.ts # RelayPool Singleton (applesauce-relay)
│ │ ├── loaders.ts # loadPostList, loadPost, loadReplies, loadReactions
│ │ ├── signer.ts # NIP-07 Wrapper (applesauce-signers)
│ │ └── outbox.ts # NIP-65 Read-Relay-Resolution für fremde Autoren
│ ├── render/
│ │ ├── markdown.ts # renderMarkdown(md: string): string — SINGLE EXPORT
│ │ └── naddr.ts # nip19.naddrEncode Helper
│ └── components/
│ ├── PostCard.svelte
│ ├── PostView.svelte
│ ├── ReplyList.svelte
│ ├── Reactions.svelte
│ └── ReplyComposer.svelte
└── routes/
├── +layout.svelte
├── +page.svelte
├── archives/+page.svelte
├── tag/[name]/+page.svelte
├── impressum/+page.svelte
└── [year]/[month]/[day]/[dtag=html_extension]/+page.svelte # s. Hinweis unten
```
### Datenfluss: Post-Seite
1. Browser ruft `/2024/01/26/offenheit-das-wesentliche.html/` auf.
2. All-Inkl `.htaccess``/index.html` (Fallback, keine Datei unter dem Pfad).
3. SPA bootet, SvelteKit-Router matcht Route, extrahiert `dtag`.
4. `loadPost(dtag)` fragt via `applesauce-loaders` den `RelayPool` mit Filter `{ kinds:[30023], authors:[PUBKEY], '#d':[dtag] }`.
5. Observable emittiert Event (bei Versionen: neueste gewinnt, replaceable-Semantik via `applesauce`).
6. `renderMarkdown(event.content)` → sanitized HTML → `{@html}` in `PostView`.
7. Parallel: `loadReplies(event)` (kind:1 mit `#a` oder `#e`) und `loadReactions(event)` (kind:7), nonblocking.
### Datenfluss: Home/Archiv
```
loadPostList() → req({ kinds:[30023], authors:[PUBKEY], limit:100 })
→ Observable streamt Events
→ Dedup per d-Tag, sortiert nach published_at desc
→ PostCard-Liste rendert reaktiv
```
### Datenfluss: Kommentar schreiben (NIP-07)
```
ReplyComposer öffnet
→ signer.getPublicKey() # Alby/nos2x prompted ggf.
→ User tippt, klickt Senden
→ Event bauen (kind:1, Tags: ['a', addr], ['e', eventId], ['p', authorPk])
→ signer.signEvent(event) # Extension prompted zur Freigabe
→ pool.publish(writeRelays, signedEvent)
→ bestehende ReplyList-Subscription zeigt Event optimistisch sofort an
```
`writeRelays` kommt bevorzugt aus dem NIP-65-Outbox-Event des Signers; Fallback: `READ_RELAYS`.
### State-Management
RxJS-Observables direkt in Svelte konsumiert:
```svelte
<script>
import { loadPost } from '$lib/nostr/loaders'
export let data
const post$ = loadPost(data.dtag)
</script>
{#if $post$}
<article class="prose">{@html renderMarkdown($post$.content)}</article>
{:else}
<p>Lade …</p>
{/if}
```
Keine separaten Svelte-Stores. Observable ist der Store.
### Fehler- und Loading-Zustände
- **Soft-Timeout 8s:** „noch am Suchen …".
- **Hard-Timeout 15s:** Fallback-Hinweis mit Habla-Deeplink (`https://habla.news/a/<naddr>`).
- **Kein Event gefunden:** 404-Komponente mit Habla-Link.
- **Alle Relays offline:** Banner + Retry-Button.
- **Replies/Reactions:** best-effort, Fehler silently — dürfen die Post-Anzeige nicht blockieren.
### Markdown-Rendering (Isolation für Zukunft)
Alles Rendering-Zeug in `src/lib/render/markdown.ts`. Externer Export genau eine Funktion:
```ts
export function renderMarkdown(md: string): string
```
Kein Import von `marked` oder `DOMPurify` außerhalb dieser Datei. Das macht Austausch (z. B. zu `svelte-markdown`, `markdown-it` oder `unified`) später zu einem lokal begrenzten Refactor.
Implementierung (Skizze):
```ts
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import bash from 'highlight.js/lib/languages/bash'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('bash', bash)
marked.use({
breaks: true,
gfm: true,
renderer: {
code(code, lang) {
const highlighted = lang && hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang }).value
: hljs.highlightAuto(code).value
return `<pre><code class="hljs language-${lang ?? ''}">${highlighted}</code></pre>`
},
link(href, title, text) {
const internal = href?.startsWith('/') || href?.includes('joerg-lohrer.de')
const attrs = internal ? '' : ' target="_blank" rel="noopener"'
return `<a href="${href}"${attrs}>${text}</a>`
},
},
})
export function renderMarkdown(md: string): string {
const raw = marked.parse(md) as string
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] })
}
```
### Sicherheit
- **DOMPurify für allen gerenderten Content** — Hauptposts (von dir signiert) und Replies (von Dritten).
- **Pubkey-Whitelist:** PostsLoader filtert strikt auf `authors:[PUBKEY]`. Fremde Events für Hauptinhalte nicht anzeigen. Replies/Reactions explizit ausgenommen, aber sanitized.
- **Privat-Key liegt nie im Bundle, nicht auf All-Inkl, nicht im Browser.** Signieren ausschließlich lokal (Publish-Spec) oder via NIP-07 (Besucher-Kommentare).
### Bundle-Budget
Schätzung gzip:
- SvelteKit Runtime: ~15 KB
- applesauce-relay + -loaders + -signers: ~30 KB
- rxjs (tree-shaken): ~15 KB
- marked + DOMPurify: ~25 KB
- highlight.js Core + 3 Sprachen: ~15 KB
- App-Code: ~10 KB
- **Total: ~110 KB gzip** — vertretbar.
---
## 4. Hosting, Deployment, Migrationspfad
### Hosting bei All-Inkl
Webhosting-Paket, Standardfeatures:
- statische Dateiauslieferung
- `mod_rewrite` (Standard)
- HTTPS (Let's Encrypt inklusive, ggf. schon aktiv)
Keine PHP, kein MySQL, keine Cronjobs, kein Backend.
### Dateistruktur auf Webspace
```
/
├── index.html # SPA-Shell
├── _app/ # SvelteKit-Bundle
├── .htaccess
├── robots.txt
├── favicon.ico
├── impressum/
│ └── index.html # statische HTML-Datei
├── 2024/01/26/offenheit-das-wesentliche.html/
│ ├── bild1.jpeg # Altbilder
│ └── bild2.jpeg
├── 2023/03/23/gleichnis-vom-saemann.html/
│ └── …
└── …
```
### Upload-Mechanik
- **SvelteKit-Bundle:** `npm run build``build/` → rsync/FTP in Webspace-Root. npm-Script `deploy`.
- **Altbilder:** einmalig per rsync aus `content/posts/` an die Permalink-Pfade übertragen.
- **Neue Bilder:** nicht auf All-Inkl (Phase 2, Blossom-Upload via Publish-Spec).
**Credentials:** FTP-Login in `.env` des lokalen Repos, nicht committed.
### Migrationspfad (die 18 bestehenden Posts)
Um Verwechslung mit den „Phasen" im Evolutionspfad (Abschnitt 5) zu vermeiden, werden die Migrationsschritte mit **Schritt AE** bezeichnet.
**Schritt A — Pre-Launch:**
Hugo-Seite bleibt live, nichts anfassen.
**Schritt B — SPA entwickeln:**
Lokal bauen, gegen Public-Relays mit Test-Events validieren.
**Schritt C — Posts als Events publizieren (Publish-Spec):**
Alle 18 `.md` zu `kind:30023` signieren und zu 45 Public-Relays pushen.
`d`-Tag = bisheriger Hugo-Slug; `published_at` = Frontmatter-Datum.
Verifikation: Events in Habla-Client gegenchecken.
**Schritt D — Cutover:**
1. Altbilder in Permalink-Pfade hochladen.
2. SPA-Bundle in Webspace-Root deployen.
3. `index.html` und `.htaccess` ersetzen (alte Hugo-Version wird überschrieben).
4. Alte, nicht mehr benötigte Hugo-Artefakte können bleiben oder gelöscht werden; `.htaccess`-Fallback macht sie harmlos.
**Schritt E — Validierung:**
- Alle 18 URLs im Browser prüfen (Inhalt stimmt, Bilder laden).
- Stichprobenhaft `curl -A "Mozilla/5.0" <url>` → Shell wird ausgeliefert.
- Link auf Mastodon posten → Preview fehlt (erwartet im Minimal-Launch).
**Rollback-Fähigkeit:**
Alter Hugo-`public/`-Stand als ZIP lokal archivieren. Rollback = zurückkopieren.
### URL-Kompatibilität (verifiziert)
Hugo-URL: `https://joerg-lohrer.de/2024/01/26/offenheit-das-wesentliche.html/`
Neu: Pfad existiert nicht als Datei → `.htaccess``/index.html` → SPA routed via `dtag`.
Für externe Links: identische URL, identische Inhaltsanzeige. Backlinks aus Mastodon, Google, Bookmarks funktionieren weiter.
---
## 5. Testing, Risiken, Evolution
### Testing-Strategie
**Unit-Tests (Vitest):**
- `renderMarkdown()`: Input/Output, XSS-Vektoren (`javascript:`-URLs, `<script>` in Content).
- `naddr`-Encoder: Pubkey + Kind + d-Tag → erwarteter Bech32-String.
- URL-Parser: `/2024/01/26/foo.html/``{ dtag: 'foo' }`.
**Integration-Tests:**
- Mock-Relay (in-memory Fake oder `nostr-relay-tray` in Testmodus).
- PostsLoader: mehrere Versionen desselben `d`-Tags → neueste wins.
- Signer: Test-Key signiert, PostLoader ruft ab, Inhalt matcht.
**End-to-End (Playwright):**
- Happy Path: Start → Liste → Klick → Post rendert.
- Deep-Link: `/2024/01/26/offenheit-das-wesentliche.html/` direkt.
- Kommentar mit Mock-NIP-07-Signer.
**Manuelle Tests vor Go-Live:**
- Alle 18 Post-URLs durchklicken, Visual-Parity-Check gegen alte Hugo-Seite.
- `curl`-Tests mit verschiedenen User-Agents.
- Offline-Fall: WLAN aus → Fehlermeldung lesbar, Habla-Fallback verfügbar.
- Reales NIP-07-Kommentar via Alby auf Test-Post.
### Risiken & Mitigationen
| Risiko | Wahrsch. | Auswirkung | Mitigation |
|---|---|---|---|
| Public-Relays alle down | niedrig | hoch | 45 Relays parallel, Timeout-UI, Habla-Fallback |
| Relay löscht Events | niedrig | mittel | Mehrere Relays, bezahltes nostr.wine als Anker |
| Dependency-Break | mittel | hoch | `package-lock.json` committen, `npm ci`, Staging vor Prod |
| `.htaccess` rewritet Bilder irrtümlich | niedrig | mittel | Regel prüft `-f` vor Fallback |
| Google-Rankings brechen | **hoch** | **mittel** | Akzeptiert; Stubs in Phase 3 nachrüstbar |
| Mastodon-Preview leer | **hoch** | **niedrig** | Akzeptiert; Teaser-Text im Toot kompensiert |
| XSS über Reply | niedrig | hoch | DOMPurify ohne Ausnahmen |
| All-Inkl ändert Apache-Config | sehr niedrig | mittel | Support-Ticket, Standard-Feature |
| Privat-Key-Leak | niedrig | **katastrophal** | Key niemals in Repo, Bundle, Server. Evtl. NIP-46 Bunker |
| Reaktions-Spam | mittel | niedrig | Aggregiert anzeigen, Author-Blocklist |
### Nicht Teil dieser Spec
- **Publish-Pipeline** (Signieren, Upload, GitHub Action): separate Spec. Diese Spec definiert nur Event- und URL-Kontrakt.
- **Eigener Relay/Blossom:** Evolutionspfad, nicht jetzt.
- **Impressum-Text:** rechtliche Inhalte, Umsetzung.
- **Visuelles Design:** Orientierung an PaperMod, Details in Implementation.
### Evolutionspfad
**Phase 1 (jetzt):** Minimal-Launch. SPA auf All-Inkl, Public-Relays, Altbilder auf All-Inkl, Kommentare via NIP-07. Kein SEO, keine Social-Previews.
**Phase 2 (nah):** Blossom für neue Bilder (public Blossom-Server, BUD-03-Multi-Upload, eine URL im Markdown). SPA unverändert.
**Phase 3 (bei Bedarf):** Meta-Stubs nachrüsten für SEO/Social-Previews. Publish-Pipeline erweitern, SPA unverändert.
**Phase 4 (ideologisch):** Eigener strfry-Relay (Homeserver oder VPS), zu Publish-Liste und SPA-Read-Liste hinzu.
**Phase 5 (vollständig dezentral):** Eigener Blossom-Server. Neue Posts uploaden dorthin, alte optional migrieren.
Jeder Phasenwechsel: additiv oder lokal begrenzter Refactor, kein Rewrite.
### Success-Kriterien Phase 1
- Alle 18 alten Post-URLs liefern korrekten Inhalt (Visual-Parity, nicht pixelgenau).
- Neuer Post publizieren (Publish-Spec) < 30 s lokal.
- First Contentful Paint < 1,5 s auf Desktop/LAN.
- Time-to-Post-Rendered < 3 s (Shell + Relay + Event + Rendering).
- Lighthouse Accessibility > 90.
- NIP-07-Kommentar funktioniert in Chrome + Firefox mit Alby.
---
## Anhang: Begriffe
- **NIP-23:** Nostr-Langform-Events, `kind:30023`, replaceable per `d`-Tag.
- **NIP-07:** Browser-Extension-Signer-Protokoll (Alby, nos2x, Flamingo).
- **NIP-65:** Outbox-Model, `kind:10002`, definiert Read/Write-Relays pro Autor.
- **naddr:** Bech32-kodierter Pointer auf ein parameterized-replaceable Event (Pubkey + Kind + d-Tag + Relays).
- **Blossom:** Content-addressed Blob-Hosting für Nostr, Dateien über SHA256-Hash adressiert.
- **BUD-03:** Blossom-User-Description-03, Multi-Server-Mirror-Spezifikation.
- **Replaceable Event:** Event, das alte Versionen mit gleichem (Pubkey, Kind, d-Tag) ersetzt. Relays halten nur die neueste Version.