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

21 KiB
Raw Blame 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

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

Relay-Liste kommt aus dem NIP-65-Outbox-Event (kind:10002) des Autors. Das Event wird einmalig manuell publiziert (siehe Publish-Spec, Abschnitt „Pre-Flight-Setup") und enthält die bevorzugten Read- und Write-Relays.

Auflösung zur Laufzeit:

  1. SPA kennt genau einen hartcodierten Bootstrap-Relay (wss://relay.damus.io).
  2. Beim Boot: SPA fragt Bootstrap-Relay nach { kinds:[10002], authors:[PUBKEY] }.
  3. Aus dem Event werden die ["r", <url>]-Tags extrahiert (Read-Relays für die SPA-Abfragen).
  4. Diese Liste wird für alle weiteren Nostr-Requests genutzt.

Fallback: Falls Bootstrap-Relay nicht antwortet oder kind:10002 nicht existiert, nutzt die SPA eine hartcodierte Fallback-Liste.

// src/lib/nostr/config.ts
export const BOOTSTRAP_RELAY = 'wss://relay.damus.io'

// TODO bei Implementierung: npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9
// in hex decodieren (nip19.decode) und hier eintragen.
export const AUTHOR_PUBKEY_HEX = '<hex wird bei Implementierung aus npub abgeleitet>'

// Nur Fallback: wenn kind:10002 nicht geladen werden kann.
export const FALLBACK_READ_RELAYS = [
  'wss://relay.damus.io',
  'wss://nos.lol',
  'wss://relay.nostr.band',
  'wss://nostr.wine',
]

Vorteil: Änderungen an der Relay-Liste (z. B. späteres Hinzufügen eines eigenen Relays) erfordern nur ein neues kind:10002-Event, keinen Code-Deploy.

Blossom-Server-Liste wird analog aus kind:10063 (BUD-03) aufgelöst — siehe Publish-Spec für das normative Schema beider Events.


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:

<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:

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):

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 buildbuild/ → 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.