From 4918175f8b52feb329ab4ac6a946725dac9b2691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Wed, 15 Apr 2026 09:25:11 +0200 Subject: [PATCH] =?UTF-8?q?spec:=20nostr-page=20auf=20basis=20von=20events?= =?UTF-8?q?=20=E2=80=94=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SvelteKit-SPA, die Blog-Posts live aus signierten kind:30023-Events von Public-Relays rendert. Ablösung der Hugo-Seite auf All-Inkl ohne eigene Infrastruktur in Phase 1; Stubs, eigener Relay und Blossom als Evolutionspfad. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-15-nostr-page-design.md | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-nostr-page-design.md diff --git a/docs/superpowers/specs/2026-04-15-nostr-page-design.md b/docs/superpowers/specs/2026-04-15-nostr-page-design.md new file mode 100644 index 0000000..d65a13a --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-nostr-page-design.md @@ -0,0 +1,470 @@ +# 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/.html/` | SPA-Shell (via `.htaccess`-Fallback). SPA-Router extrahiert `` und lädt Event. | +| `/archives/` | SPA-Shell, SPA rendert chronologische Liste. | +| `/tag//` | SPA-Shell, SPA rendert Tag-Filter. | +| `/impressum/` | Statisches HTML (rechtlicher Content, liegt wirklich auf Server). | +| `/YYYY/MM/DD/.html/` | 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:[], #d:[]`. 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", ""]` — URL-Slug. Stabiler Identifier über Edits hinweg (replaceable-Semantik). +- `["title", ""]` +- `["published_at", ""]` — ursprüngliches Veröffentlichungsdatum. + +**Empfohlene Tags:** + +- `["summary", ""]` — genutzt in Listen-Previews. +- `["image", ""]` — Vorschaubild. +- `["t", ""]` — mehrfach erlaubt, Kategorien/Tags. + +**Content:** Markdown. Bilder als Markdown-Syntax mit absoluten URLs. + +**Bild-URL-Policy pro Post-Ära:** + +- Migrations-Posts (bestehende 18 Posts, 2013–2024): 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 2–3 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 = '' +``` + +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` (3–5 Sprachen registriert). +- **Signing:** NIP-07 (Browser-Extension wie Alby oder nos2x). + +### Dateistruktur + +``` +src/ +├── app.html # SPA-Shell -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 + + +{#if $post$} +
{@html renderMarkdown($post$.content)}
+{:else} +

Lade …

+{/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/`). +- **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 `
${highlighted}
` + }, + link(href, title, text) { + const internal = href?.startsWith('/') || href?.includes('joerg-lohrer.de') + const attrs = internal ? '' : ' target="_blank" rel="noopener"' + return `${text}` + }, + }, +}) + +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 A–E** 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 4–5 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" ` → 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, `