diff --git a/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md b/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md new file mode 100644 index 0000000..435d747 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md @@ -0,0 +1,677 @@ +# Publish-Pipeline für Nostr-Events — Design-Spec + +**Datum:** 2026-04-15 +**Status:** Entwurf, ausstehende User-Freigabe +**Scope:** Toolchain, die Markdown-Posts aus `content/posts/*/index.md` in signierte Nostr-Events (`kind:30023`, NIP-23) umwandelt, zu Relays publiziert, und die zugehörigen Bilder zum Asset-Host (All-Inkl für Altposts, Blossom für neue) hochlädt. + +Diese Spec ist die Schwester-Spec zu [`2026-04-15-nostr-page-design.md`](2026-04-15-nostr-page-design.md) und teilt sich mit ihr den Event-Kontrakt für `kind:30023` und die Konfiguration über `kind:10002` / `kind:10063`. + +--- + +## 1. Gesamtarchitektur + +``` + Auslöser + ┌───────────────────┬───────────────────┐ + │ │ │ + ▼ ▼ ▼ + Lokaler CLI GitHub Action workflow_dispatch + `deno task (push auf main, (--force-all, z. B. + publish` wenn content/ für Migration oder + posts/** geändert) Reimport) + │ │ │ + └───────────────────┴───────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ Publish-Pipeline (Deno) │ + │ gemeinsame Library + CLI │ + │ │ + │ 1. Nostr-Kontext laden: │ + │ • kind:10002 (Relays) │ + │ • kind:10063 (Blossom) │ + │ 2. Change-Detection │ + │ (Git-Diff oder force) │ + │ 3. Pro Post: │ + │ a. Frontmatter parsen │ + │ b. Markdown transform │ + │ c. Bilder upload │ + │ (legacy/blossom) │ + │ d. Event bauen │ + │ e. Via NIP-46 signieren │ + │ f. Zu Relays pushen │ + └──────┬──────────────────────┘ + │ + ┌──────────┼──────────────┬──────────────┐ + ▼ ▼ ▼ ▼ + Amber Public Blossom- All-Inkl + (NIP-46 Nostr- Server (rsync + Signer Relays (primal, over SSH, + via aus später eigen) Altbilder + Relay) kind:10002 aus der 18 + kind:10063) Migrations- + posts) +``` + +### Kernprinzipien + +- **Deno als Runtime.** Native TypeScript, Permissions-Modell, keine `node_modules`. +- **Gemeinsame Library + CLI.** Kernlogik in Modulen, sowohl von lokaler CLI als auch von CI-Workflow importiert. Keine Duplikation. +- **Nostr als Source-of-Truth für Konfiguration.** Relay-Liste aus `kind:10002`, Blossom-Serverliste aus `kind:10063`. Keine YAML-Config im Repo. +- **NIP-46 Bunker für Signaturen.** Der private Schlüssel liegt nie in der Pipeline-Umgebung (nicht lokal, nicht in CI-Secrets). Bunker-Stufe Amber zum Start, Bunker-Stufe Optiplex nachrüstbar ohne Code-Change. +- **Git-Diff als Change-Detection.** Pipeline publisht nur geänderte Posts. Override-Flag für Migration und Reimport. +- **State-los im Repo.** Keine Lock-Files, kein Commit-zurück. CI ist read-only auf Repo-Content. +- **Idempotenz.** Wiederholte Läufe ohne inhaltliche Änderung erzeugen keine neuen Events (Git-Diff filtert). + +### Kostenübersicht + +- Deno: 0 €. +- Amber, Public-Relays, Public-Blossom: 0 €. +- GitHub-Actions: im Free-Tier für persönliche Repos ausreichend. +- All-Inkl: unverändert, bereits Premium-Tarif für SSH. +- **Zusatzkosten: keine.** + +### Out-of-Scope + +Diese Spec behandelt nicht: +- **Kommentare/Reactions auf Posts.** Die kommen von Besuchern über die SPA via NIP-07 (siehe SPA-Spec §3). Publish-Pipeline publisht ausschließlich Autor-eigene `kind:30023`. +- **SPA-Deployment** (SvelteKit-Bundle-Upload). Wird in einem separaten Deploy-Mechanismus behandelt oder als optionaler Subcommand nachgerüstet. +- **Domain-Verwaltung, TLS-Zertifikate, All-Inkl-Paketwahl.** Infrastruktur-seitig außerhalb der Pipeline. + +--- + +## 2. Pre-Flight-Setup + +Bevor der erste Publish-Lauf erfolgen kann, müssen folgende Bedingungen einmalig manuell erfüllt sein. Der Subcommand `deno task check` verifiziert die Punkte und gibt klare Fehlermeldungen aus, wenn etwas fehlt. + +### 2.1 Nostr-Identität + +- Pubkey des Autors: **npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9** +- Privater Schlüssel (`nsec`) existiert nur in **Amber** auf dem Handy des Autors. Keine andere Instanz hält ihn. + +### 2.2 NIP-46-Bunker-Pairing (Amber) + +1. Auf dem Handy: Amber öffnen, Account wählen. +2. In Amber: „Generate Bunker URL" o. ä. — erzeugt eine `bunker://?relay=wss://...&secret=...` URL. +3. Im Handy-Amber: Permission-Regeln setzen: + - `kind:30023` signieren → **auto-approve** für die Publish-Pipeline-App + - alle anderen Kinds → prompt (Sicherheitsnetz, sollte nicht aufschlagen) +4. Bunker-URL in die Pipeline-Umgebung einfügen: + - **Lokal:** in `.env` als `BUNKER_URL=bunker://...` (in `.gitignore`) + - **CI:** als GitHub-Actions-Secret `BUNKER_URL` +5. Amber muss während CI-Runs online sein (WLAN oder mobile Daten). Akku-Optimierung für Amber auf dem Handy deaktivieren. + +### 2.3 Relay-Liste (`kind:10002`) + +Einmalig manuell publizieren via Nostr-Client (z. B. nostrudel.ninja mit Amber-Login, oder direkt aus Amber). + +**Schema (NIP-65):** + +```json +{ + "kind": 10002, + "pubkey": "", + "tags": [ + ["r", "wss://relay.damus.io"], + ["r", "wss://nos.lol"], + ["r", "wss://relay.nostr.band"], + ["r", "wss://nostr.wine"] + ], + "content": "", + "created_at": +} +``` + +**Lese-Semantik der Pipeline (NIP-65):** + +- `["r", ]` ohne drittes Element → Relay ist sowohl Read als auch Write. +- `["r", , "read"]` → nur Read; Pipeline **ignoriert** beim Publish. +- `["r", , "write"]` → nur Write; Pipeline nutzt beim Publish. + +Phase 1: alle Einträge ohne drittes Element (beides). Spätere Differenzierung möglich, ohne Code-Änderung. + +Replaceable Event (kein `d`-Tag) — bei späteren Updates (z. B. eigener Relay hinzu) wird einfach ein neues `kind:10002` publiziert, das das alte ersetzt. + +### 2.4 Blossom-Serverliste (`kind:10063`) + +Einmalig manuell publizieren. Phase-1-Inhalt: ein Server. + +**Schema (BUD-03):** + +```json +{ + "kind": 10063, + "pubkey": "", + "tags": [ + ["server", "https://blossom.primal.net"] + ], + "content": "", + "created_at": +} +``` + +Phase-5-Erweiterung (eigener Blossom-Server): zusätzliches `["server", "https://blossom.joerg-lohrer.de"]` wird vorne in die Liste aufgenommen, neues Event publiziert. + +### 2.5 SSH-Deploy-Key für All-Inkl + +1. Lokal Keypair erzeugen, **dediziert für Deploys**, nicht persönlicher SSH-Key: + ``` + ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_joerglohrerde_deploy -C "deploy-joerglohrerde" + ``` + Ohne Passphrase (CI braucht non-interactive Zugang). +2. Public-Key-Inhalt (`*.pub`) in All-Inkl-KAS unter „SSH-Zugänge" → „Authorized Keys" eintragen. +3. Verbindung testen: `ssh -i ~/.ssh/id_ed25519_joerglohrerde_deploy w00xxxxx@ssh.all-inkl.com` +4. Private-Key bereitstellen: + - **Lokal:** liegt in `~/.ssh/` und wird von rsync automatisch genutzt. + - **CI:** als GitHub-Actions-Secret `SSH_DEPLOY_KEY` (Inhalt der privaten Key-Datei). Im Workflow wird er in `~/.ssh/id_ed25519` gechrieben und `chmod 600` gesetzt. + +### 2.6 All-Inkl Deploy-Root + +Nach Tarifwechsel auf Premium: Pfad im KAS unter „Dateiverwaltung" ablesen. Typisch: `w00xxxxx@ssh.all-inkl.com:joerg-lohrer.de/`. + +- **Lokal:** in `.env` als `ALLINKL_DEPLOY_ROOT` +- **CI:** als GitHub-Actions-Secret + +### 2.7 `deno task check` + +Dieser Subcommand verifiziert alle obigen Punkte: + +- `BUNKER_URL` gesetzt, Bunker antwortet auf Ping, Pubkey stimmt mit `AUTHOR_PUBKEY_HEX` überein. +- `kind:10002` auf Bootstrap-Relay gefunden, mindestens 1 Relay eingetragen. +- `kind:10063` auf Bootstrap-Relay gefunden, mindestens 1 Server eingetragen. +- SSH-Verbindung zu `ALLINKL_DEPLOY_ROOT` erfolgreich (`ssh ... echo ok`). +- Deno-Version und benötigte Permissions. + +Bei jedem Fehler: klare Text-Meldung, was zu tun ist (z. B. „kind:10002 fehlt — publiziere es manuell mit folgendem Schema: ..."). + +--- + +## 3. Event-Kontrakt (normativ) + +### 3.1 `kind:30023` — Blog-Post (NIP-23) + +**Pflicht-Tags:** + +- `["d", ""]` — Slug-String, identisch mit Frontmatter `slug:`. Lowercase und URL-kompatibel (a–z, 0–9, `-`). Ist Teil des Tupels `(pubkey, kind, d)` für Replaceable-Semantik. +- `["title", ""]` — aus Frontmatter `title:`. +- `["published_at", ""]` — aus Frontmatter `date:`, als Unix-Zeitstempel in Sekunden. **Stabil** über Edits hinweg — ändert sich nie. + +**Empfohlene Tags (wenn im Frontmatter vorhanden):** + +- `["summary", ""]` — aus Frontmatter `description:`. +- `["image", ""]` — aus Frontmatter `cover.image:` (oder `image:`), transformiert zur absoluten URL gemäß Abschnitt 4. +- `["t", ""]` — ein Tag-Element pro Eintrag in Frontmatter `tags:`. Tag-Strings unverändert übernommen (Groß-/Kleinschreibung erhalten, weil Tag-Konvention im Nostr-Ökosystem case-sensitive ist). + +**Event-Header:** + +- `kind`: 30023 +- `pubkey`: `AUTHOR_PUBKEY_HEX` +- `created_at`: Unix-Zeitstempel des Signatur-Zeitpunkts (ändert sich bei jedem Edit). +- `content`: Markdown-Body nach Bild-URL-Transformation (Abschnitt 4). + +**Nicht gemappt** (Hugo-spezifische Frontmatter-Felder ohne Nostr-Entsprechung): + +`layout`, `cover.caption`, `cover.alt`, `author`, `lang`, `dir`, `toc`, `toc_label`, `toc_icon`, `comments`, `weight`, `menus`, `aliases`, `draft`. + +**Draft-Behandlung:** `draft: true` im Frontmatter → Pipeline publisht diesen Post **nicht** (überspringt ohne Fehler, loggt Info). + +### 3.2 `kind:10002` — NIP-65 Outbox-Relays + +Siehe Abschnitt 2.3. Von der Publish-Pipeline nur **gelesen** (Bootstrap beim Start); nicht von der Pipeline publiziert. + +### 3.3 `kind:10063` — BUD-03 Blossom-Serverliste + +Siehe Abschnitt 2.4. Von der Publish-Pipeline nur **gelesen** (vor Blossom-Upload); nicht von der Pipeline publiziert. + +### 3.4 Signing + +Alle ausgehenden Events werden via **NIP-46 Bunker** signiert (nicht NIP-07 — dieser Flow ist rein browserseitig und für die CLI nicht anwendbar). Implementierung via `applesauce-signers` `Nip46Signer`: + +```ts +const signer = new Nip46Signer(BUNKER_URL) +await signer.getPublicKey() // initialisiert Verbindung +const signed = await signer.signEvent(unsignedEvent) +``` + +--- + +## 4. Markdown- und Bild-Transformation + +### 4.1 Frontmatter-Parsing + +YAML-Frontmatter zwischen `---`-Trennern. Parser: `jsr:@std/yaml` oder `npm:gray-matter`. + +Slug kommt als **lowercase String** aus dem Frontmatter-Feld `slug:`. Ist bereits normalisiert (siehe Commit `d17410f`) — Pipeline muss nichts ableiten oder lowercasen. + +**Validierung:** +- `title`, `date`, `slug` müssen vorhanden sein; sonst harter Fehler für diesen Post. +- `slug` muss regex `^[a-z0-9][a-z0-9-]*$` matchen; sonst harter Fehler. + +### 4.2 Bild-URL-Transformation + +Ziel: alle relativen Bild-Referenzen im Markdown-Body werden zu absoluten URLs. + +**Erkannte Muster:** +- `![alt](filename)` — reguläre Markdown-Bild-Syntax. +- `[![alt](filename)](link)` — Bild-in-Link-Konstrukt. +- `![alt](filename =WxH)` — mit Größen-Suffix (Obsidian/PaperMod-Erweiterung). + +**Regeln:** +1. Wenn `filename` ein Schema enthält (`http://`, `https://`, `//`), nicht transformieren — ist schon absolut. +2. Ansonsten zu absoluter URL machen; URL-Kodierung pro Pfad-Segment via `encodeURIComponent()`. +3. `=WxH`-Suffix entfernen; die SPA skaliert Bilder per CSS responsiv. + +**Basis-URL je nach `image_source`-Frontmatter:** + +- Wenn `image_source: legacy` → `https://joerg-lohrer.de///
/.html/` + - `YYYY/MM/DD` aus `date:`-Frontmatter, nicht aus dem Signatur-Zeitpunkt. + - `` ist identisch mit `slug`. +- Wenn `image_source` fehlt oder `image_source: blossom` → Blossom-URL; siehe Abschnitt 5. + +### 4.3 `image_source`-Flag + +**Einmaliger Migrationsschritt (vor erstem Publish-Lauf):** Die 18 Altposts bekommen `image_source: legacy` ins Frontmatter geschrieben. Das ist ein separater Commit, kein Pipeline-Feature. + +**Neue Posts:** kein Flag nötig, Default = `blossom`. Wenn ein zukünftiger Post explizit auf All-Inkl zeigen soll (außergewöhnlich), kann `image_source: legacy` gesetzt werden. + +### 4.4 Cover-Image-Tag + +Das `image`-Tag im Event (für Listen-Previews/OG-Vorschau in Nostr-Clients) kommt aus dem Frontmatter (nicht aus dem Markdown-Body): + +- Quelle: `cover.image:` (Hugo-Page-Bundle-Konvention); Fallback `image:` auf Top-Level. +- Ist typischerweise ein relativer Dateiname. +- Wird durch denselben URL-Bauer wie die Body-Bilder geschickt (Abschnitt 4.2), aber der Input ist ein direkter Dateiname aus YAML, nicht aus Markdown-Syntax. Keine `=WxH`-Suffix-Erkennung nötig. +- Ergebnis: absolute URL gemäß `image_source`-Policy. + +--- + +## 5. Upload-Pfade + +### 5.1 Legacy-Upload (All-Inkl) + +Betrifft: die 18 Altposts, Bilder darin. + +**Mechanik:** `rsync` over SSH via `Deno.Command("rsync", [...])`. + +**Befehlsschema:** + +``` +rsync -avz --no-perms --no-times \ + -e "ssh -i $DEPLOY_KEY_PATH -o StrictHostKeyChecking=accept-new" \ + /*.{png,jpg,jpeg,gif,webp,svg} \ + $ALLINKL_DEPLOY_ROOT//
/.html/ +``` + +- **Idempotent:** rsync überträgt nur neue/geänderte Dateien. +- **Nicht-löschend:** ohne `--delete`. Alte Bilder bleiben auf dem Server liegen, keine automatische Bereinigung. Manueller Aufräum-Bedarf wird hingenommen (Tote Dateien verursachen keinen Schaden, Storage ist billig). +- **Zielordner erzeugen:** rsync legt fehlende Ordner per `--mkpath` oder (wenn Version zu alt) per vorgeschaltetem `ssh ... mkdir -p` an. + +**Neuer Post-Edit mit alten Bildern:** falls jemand mal einen Post editiert, der `image_source: legacy` hat und neue Bilder hinzufügt → diese werden auch zu All-Inkl geschoben. Das ist okay. Das Flag steuert nur den URL-Basispfad, nicht die Intention „nie wieder All-Inkl". + +### 5.2 Blossom-Upload + +Betrifft: alle neuen Posts (`image_source: blossom` oder fehlend). + +**Mechanik:** BUD-01 HTTP-Upload zu allen Servern aus `kind:10063`-Liste, parallel. + +**Schritte pro Bild:** + +1. SHA256-Hash der Datei berechnen. +2. Authorization-Event (`kind:24242`) bauen und via Bunker signieren (enthält Hash, Verb `upload`, Expiration). +3. HTTP `PUT /upload` gegen alle Server gleichzeitig mit Auth-Header `Nostr `. +4. Antworten sammeln: pro Server entweder `200 { url, sha256, ... }` oder Fehler. +5. Erfolg: mindestens 1 Server hat die Datei akzeptiert. Optimal: alle. +6. Markdown-URL nutzt die URL des **ersten Servers** aus der `kind:10063`-Liste (deterministisch, reproduzierbar). + +**Failure-Modi:** +- Alle Server lehnen ab → harter Fehler, Pipeline bricht für diesen Post ab. +- Manche Server OK, manche Fehler → Warnung in Log, Pipeline fährt fort mit erfolgreichem Upload. + +**Retry:** 2 Versuche pro Server mit exponentiellem Backoff. + +--- + +## 6. Change-Detection und Workflow + +### 6.1 Welche Posts werden publiziert? + +**Modus 1 — Git-Diff (Standard):** + +Pipeline vergleicht Dateiliste zwischen `HEAD~1` (lokal) bzw. `${{ github.event.before }}` (CI) und `HEAD`. Alle `.md` in `content/posts/**/`, die darin als `A` (added), `M` (modified) oder `R` (renamed) auftauchen, werden publiziert. + +**Modus 2 — `--force-all` (Migration / Reimport):** + +Alle `content/posts/**/*.md` werden publiziert, unabhängig von Git-Diff. Verwendet für: +- Initiale Migration der 18 Altposts (einmaliger lokaler Lauf). +- Nachträgliches Reimport nach Schema-Änderungen. + +**Modus 3 — `--post ` (Einzel-Post, für Debug):** + +Nur der Post mit dem angegebenen Slug wird verarbeitet. + +### 6.2 Trigger + +**Lokal:** `deno task publish [--force-all | --post | --dry-run]`. + +**GitHub Action:** + +```yaml +on: + push: + branches: [main] + paths: ['content/posts/**'] + workflow_dispatch: + inputs: + force_all: + type: boolean + default: false +``` + +- Push auf `main` mit Content-Änderung → automatischer Publish im Git-Diff-Modus. +- Manual-Trigger via GitHub-UI → optional `force_all=true`, dann `--force-all`-Lauf. + +### 6.3 Idempotenz und Doppelpublikationen + +Bei ausschließlicher Nutzung einer Variante (nur lokal ODER nur CI) ist Git-Diff-Detection präzise. + +**Edge Case:** Wenn du lokal einen Post publisht, *ohne* zu pushen, und später CI läuft (z. B. für eine Content-Änderung an einem anderen Post), bekommt CI keinen Diff für den schon-lokal-publizierten Post — keine Doppelpublikation. Wenn du *pushst*, sieht CI im Diff die Änderung und publisht den Post erneut (dank replaceable-Semantik ist das funktional harmlos, nur etwas Relay-Bandbreite-Waste). + +**Akzeptable Redundanz.** Spec dokumentiert es, aber keine aktive Mitigation. + +### 6.4 Updates bestehender Posts + +Ein Edit eines bereits publizierten Posts führt zu einem neuen `kind:30023`-Event mit: +- Selbem `d`-Tag, selbem `pubkey`, selbem `kind` → ersetzt das alte Event (Replaceable-Semantik). +- Selbem `published_at` (Datum aus Frontmatter, unverändert). +- Neuem `created_at` (Signaturzeit). +- Geändertem `content` und ggf. Tags. + +Die Pipeline loggt explizit „**UPDATE**" vs. „**NEU**", indem sie vor dem Publish das Relay befragt, ob bereits ein Event für `(pubkey, kind, dtag)` existiert. Rein informativ; beide Pfade nutzen denselben Code. + +--- + +## 7. Fehlerbehandlung und Retries + +### 7.1 Relay-Publish + +Pro Post wird das signierte Event an alle Relays aus der `kind:10002`-Liste parallel geschickt. Pro Relay: + +- Bis zu 2 Retries mit exponentiellem Backoff (1s, 3s). +- Erfolg = Relay antwortet mit `OK true`. +- Timeout pro Versuch: 10 Sekunden. + +**Erfolgskriterium pro Post:** mindestens 2 von 4 Relays haben bestätigt. Weniger → harter Fehler, Post wird als „failed" markiert, Pipeline fährt mit nächstem Post fort, am Ende Exit-Code != 0. + +**Log pro Relay:** Status (OK / fail / timeout), Roundtrip-Zeit. + +### 7.2 Blossom-Upload + +Siehe Abschnitt 5.2. Pro Server 2 Retries, mindestens 1 Server muss akzeptieren. + +### 7.3 Legacy-Upload + +rsync-Aufruf wird bei Exit-Code != 0 einmal wiederholt (1 Retry, 3 s Pause). Bleibt der Aufruf fehlerhaft, wird der Post als failed markiert und die Pipeline fährt mit dem nächsten fort. + +### 7.4 Bunker-Signing + +- Timeout 30 Sekunden pro Signatur-Request (Handy-Wake-up berücksichtigen). +- 1 Retry bei Timeout. +- Fehler (Permission denied, Bunker offline) → harter Abbruch der gesamten Pipeline (ohne Signaturen geht nichts). + +### 7.5 Logging + +Pipeline schreibt pro Run ein strukturiertes JSON-Log: + +```json +{ + "run_id": "", + "started_at": "", + "mode": "diff | force-all | post-single", + "posts": [ + { + "slug": "offenheit-das-wesentliche", + "status": "success | failed | skipped-draft", + "action": "new | update", + "event_id": "<64-hex>", + "relays_ok": ["wss://..."], + "relays_failed": [], + "blossom_servers_ok": [], + "images_uploaded": 3, + "duration_ms": 1234 + } + ], + "ended_at": "", + "exit_code": 0 +} +``` + +- **stdout:** in menschenlesbarer Form gedruckt. +- **CI:** zusätzlich als Artefakt `publish-log.json` hochgeladen (30 Tage Retention). Keine Repo-Commits zurück. +- **Lokal:** zusätzlich in `./logs/publish-.json` (lokal in `.gitignore`). + +--- + +## 8. Modul- und Dateistruktur + +``` +publish/ +├── deno.jsonc # Imports, Tasks, Permissions +├── .env.example # Dokumentation (Commit), keine Werte +├── .gitignore # .env, logs/ +├── README.md # Quickstart +├── src/ +│ ├── cli.ts # CLI-Entrypoint (mit `@std/cli`) +│ ├── core/ +│ │ ├── config.ts # BOOTSTRAP_RELAY, AUTHOR_PUBKEY_HEX +│ │ ├── frontmatter.ts # parseFrontmatter(md): { fm, body } +│ │ ├── validation.ts # validateSlug, validatePost +│ │ ├── markdown.ts # transformImageUrls, stripSizeHints +│ │ ├── event.ts # buildKind30023(fm, body) +│ │ ├── signer.ts # NIP-46 Bunker-Wrapper +│ │ ├── relays.ts # loadOutboxRelays, publishEvent +│ │ ├── blossom.ts # loadServerList, uploadBlob +│ │ ├── legacy-upload.ts # rsync SSH wrapper +│ │ ├── change-detection.ts # gitDiff, allPostFiles, forceMode +│ │ └── log.ts # structured logger + JSON writer +│ └── subcommands/ +│ ├── publish.ts # Hauptbefehl, alle 3 Modi inkl. --dry-run +│ ├── check.ts # Pre-Flight-Validation +│ └── validate-post.ts # Einzel-Post-Check ohne Upload (nur Frontmatter/Bilder) +├── tests/ +│ ├── frontmatter_test.ts +│ ├── validation_test.ts +│ ├── markdown_test.ts +│ ├── event_test.ts +│ ├── change-detection_test.ts +│ └── fixtures/ +│ └── sample-post.md +└── .github/ + └── workflows/ + └── publish.yml # CI-Workflow +``` + +**Tests:** Deno-Standard-Test-Runner. Fokus auf Unit-Tests für pure Transformationen (frontmatter, markdown, event-bauen); Integration-Tests mit Mock-Relay und Mock-Bunker. + +--- + +## 9. Testing-Strategie + +### 9.1 Unit-Tests + +- `parseFrontmatter`: diverse Real-Beispiele aus den 18 Altposts, Edge Cases (Leerzeichen in Strings, YAML-Blocks). +- `validateSlug`: Regex-Matching-Grenzen. +- `transformImageUrls`: alle Markdown-Bild-Muster, Leerzeichen in Dateinamen, bereits absolute URLs. +- `buildKind30023`: Frontmatter → Event-Objekt, Tag-Mapping, draft-Behandlung. +- `gitDiff`: Mock `git` subprocess. + +### 9.2 Integration-Tests + +- Mock-Relay (`jsr:@welshman/relay-mock` oder einfacher in-memory WebSocket-Mock). +- Mock-Bunker: Test-Signer mit bekanntem Key. +- Full-Flow: Sample-Post → signieren → publish gegen Mock-Relay → Event vom Mock abrufen → Inhalt vergleichen. + +### 9.3 End-to-End (manuell, einmalig) + +- Auf Testnetz: Dedicated Test-Relay, Test-Pubkey, Test-Amber-Account. +- Einen Sample-Post durchschieben, in Habla.news verifizieren. + +### 9.4 Pre-Flight-Check als Test + +`deno task check` wird auch von CI vor jedem Publish-Run ausgeführt. Failed Check → Pipeline bricht ab bevor irgendwas publiziert wird. + +--- + +## 10. GitHub-Actions-Workflow + +```yaml +name: Publish Nostr Events + +on: + push: + branches: [main] + paths: ['content/posts/**'] + workflow_dispatch: + inputs: + force_all: + type: boolean + default: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # für git-diff + + - uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Setup SSH-Deploy-Key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan ssh.all-inkl.com >> ~/.ssh/known_hosts + + - name: Pre-Flight Check + env: + BUNKER_URL: ${{ secrets.BUNKER_URL }} + ALLINKL_DEPLOY_ROOT: ${{ secrets.ALLINKL_DEPLOY_ROOT }} + AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }} + run: deno task check + + - name: Publish + env: + BUNKER_URL: ${{ secrets.BUNKER_URL }} + ALLINKL_DEPLOY_ROOT: ${{ secrets.ALLINKL_DEPLOY_ROOT }} + AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }} + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: | + if [ "${{ inputs.force_all }}" = "true" ]; then + deno task publish --force-all + else + deno task publish + fi + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: publish-log + path: ./logs/publish-*.json + retention-days: 30 +``` + +--- + +## 11. Beziehung zur SPA-Spec + +Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppelt: + +**Gemeinsame Verträge** (normativ festgelegt in dieser Spec, Abschnitt 3): + +- `kind:30023` Event-Schema — Publish produziert, SPA konsumiert. +- `kind:10002` Relay-Liste — Publish liest, SPA liest. +- `kind:10063` Blossom-Liste — Publish liest beim Upload, SPA liest für Bild-Fallback (zukünftig). +- Bild-URL-Konvention für Altposts `/YYYY/MM/DD/.html/` — Publish schreibt, SPA erwartet. + +**Unabhängige Entwicklung möglich:** + +- Publish kann gegen Mock-Relay und Mock-Bunker entwickelt und getestet werden, ohne dass die SPA existiert. +- SPA kann gegen manuell via `nak` o. ä. geschriebene Test-Events entwickelt werden, ohne dass die Publish-Pipeline existiert. + +**Abhängigkeit beim Cutover (SPA-Migrationsschritte C + D):** + +- SPA kann erst live gehen, wenn die 18 Altposts als Events auf Relays liegen. +- **Schritt C** der SPA-Migration bedeutet konkret: einmaliger lokaler Lauf `deno task publish --force-all` mit dem vollständigen Altbestand. Dieser Schritt liegt zeitlich **vor** Schritt D (dem tatsächlichen Cutover auf All-Inkl). +- Voraussetzung ist, dass die Publish-Pipeline zu diesem Zeitpunkt vollständig implementiert und durch `deno task check` validiert ist. + +**Laufender Betrieb:** + +- Neue Posts: Markdown committen, CI triggert Publish. +- SPA zeigt neuen Post beim nächsten Seitenreload an (Relay-Abfrage ist live). +- Zwei unabhängige Deploy-Zyklen (Publish bei Content-Änderung, SPA-Bundle bei Code-Änderung) ohne Kopplung. + +--- + +## 12. Risiken und Mitigationen + +| Risiko | Wahrsch. | Auswirkung | Mitigation | +|---|---|---|---| +| Amber offline während CI | mittel | hoch (Pipeline bricht ab) | Clear Error; Nutzer retriggert manuell nachdem Handy verfügbar | +| Bunker-Secret leakt (Repo-Secret) | niedrig | mittel | Secret rotierbar: in Amber Pairing löschen, neu pairen, Secret aktualisieren | +| SSH-Deploy-Key leakt | niedrig | mittel | Dedicated Key, in All-Inkl-KAS revokebar | +| `kind:10002` versehentlich überschrieben (Relay-Liste leer) | niedrig | hoch | check-Subcommand prüft vor jedem Run; Pipeline bricht bei leerer Liste ab | +| Relay-Zensur (Events werden gelöscht) | niedrig | mittel | Multi-Relay-Push; zusätzlich bezahltes nostr.wine als Durability-Anker | +| Git-Diff übersieht Post (Rebase, Force-Push) | niedrig | niedrig | `--force-all` als Fallback, dokumentiert | +| Blossom-Server löscht Bild | mittel | mittel | Multi-Upload zu mehreren Servern sobald kind:10063 erweitert ist | +| `encodeURIComponent` vs. All-Inkl Apache: URL-Matching fällt auseinander | niedrig | mittel | Tests gegen reale URLs; Normalisierungs-Regel (lowercase Slugs, ASCII-Filenames bevorzugt) | +| Privater Schlüssel-Recovery | niedrig | **katastrophal** | Amber hat Backup-Mechanismus; `nsec` zusätzlich offline auf Hardware sichern | + +--- + +## 13. Evolutionspfad + +**Jetzt (Bunker-Stufe Amber, Phase 1 Blossom):** +- Handy mit Amber als einziger Signer, online während Publish-Runs. +- Ein Blossom-Server in `kind:10063` (primal). +- Legacy-Bilder auf All-Inkl für die 18 Altposts. +- Relay-Liste mit 4 Public-Relays. + +**Bunker-Stufe Optiplex (sobald Proxmox-Container läuft):** +- Self-hosted Bunker (z. B. `nak bunker` als Container), 24/7 online. +- Connection-URL im Secret rotieren; Amber bleibt als Backup/manueller Signer. +- Keine Code-Änderung in der Pipeline. + +**Phase 5 Blossom (eigener Blossom-Server auf Optiplex):** +- Zusätzlicher `server`-Tag in `kind:10063` (`https://blossom.joerg-lohrer.de`). +- Neue Posts werden automatisch auch dorthin hochgeladen (Multi-Upload). +- Markdown-URL zeigt auf Primär-Server (= erster Eintrag in Liste). Soll das der eigene sein: Liste entsprechend ordnen. + +**Optional später:** +- `deno task mirror` — Subcommand, der bestehende Bilder (z. B. vom ersten Server) auch zu später hinzugefügten Servern spiegelt. Hilft bei Blossom-Server-Wechsel. + +--- + +## 14. Success-Kriterien Phase 1 + +- `deno task check` ohne Fehler. +- 18 Altposts via einmaligem `deno task publish --force-all` publiziert. +- Jeder Post in mindestens 2 Public-Relays abrufbar, in Habla.news korrekt gerendert. +- Bilder der 18 Posts via `/YYYY/MM/DD/.html/` auf All-Inkl erreichbar. +- Ein neuer Test-Post via CI auf `main`-Push publiziert in unter 90 Sekunden ab Push. +- `publish-log.json` enthält aussagekräftige Einträge pro Post. +- Pipeline läuft ohne nsec-Exposition in irgendeiner Umgebung. + +--- + +## Anhang: Begriffe + +- **NIP-23:** Nostr-Langform-Events, `kind:30023`, replaceable per `d`-Tag. +- **NIP-46:** Nostr-Remote-Signer-Protokoll (Bunker). Signatur-Anfrage und -Antwort verschlüsselt über Relays. +- **NIP-65:** Outbox-Model, `kind:10002`, definiert Read/Write-Relays pro Autor. +- **BUD-01:** Blossom-Upload-Definition: `PUT /upload` mit Nostr-Auth-Header. +- **BUD-03:** Blossom-User-Description-03, `kind:10063` mit Server-Liste. +- **Amber:** Android-App, die als NIP-46-Signer fungiert. +- **Replaceable Event:** Ersetzt vorherige Events mit gleichem `(pubkey, kind, d)`-Tupel auf dem Relay.