103 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 ce224bc2a4 chore(release): v1.10.0
CI / pruefen (pull_request) Canceled after 0s
Seit v1.9.0 sind drei Features nach main gegangen, ohne dass die
Versionsnummer mitgezogen waere: Wochenrueckblick (#2), globale Zeitleiste
(#3) und die Suche ueber Aufgaben (#4).

Genau dieselbe Luecke wie vor v1.6.0. Der Test in changelog.test.ts prueft,
dass die oberste Fassung im Verlauf zur package.json passt - er greift aber
nicht, wenn beide gemeinsam zurueckhaengen. Ein Test, der das faende,
muesste den Verlauf gegen die Git-Historie halten; das waere mehr Maschinerie
als der Fehler wert, solange die Freigabe ohnehin von Hand laeuft.

Der Verlauf wird in der Anwendung angezeigt (Knopf an der Versionsnummer in
der Fusszeile), deshalb steht dort jetzt, was dazugekommen ist. Nachgesehen
auf 375px und 1280px: Eintraege vollstaendig, keine Beanstandung von
axe-core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 07:44:16 +02:00
florian.wilde a264eb0dce Merge #4: Suche über Aufgaben
CI / pruefen (push) Canceled after 0s
2026-08-18 05:37:54 +00:00
florian.wilde afe57c4428 Suche ueber Aufgaben
CI / pruefen (pull_request) Canceled after 0s
Zwei Luecken, die im Issue zusammen stehen.

**Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und
Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld
deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im
Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine
zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen.

Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von
letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter
"Erledigte einblenden" gilt nur ohne Suchbegriff.

**Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei,
nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt,
Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit
auch hier - "Rechnung" findet "Rechnungen".

Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der
Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst
greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen
Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe -
Gegenprobe kommt leer heraus.

Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen
haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als
eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und
Schnellsuche -, zeigen sie jetzt.

Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen
die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes
inbegriffen, Stammformen greifen, fremde Konten finden nichts.

Refs #4
2026-08-18 03:39:02 +02:00
florian.wilde 0ef0b3d5ca Merge #3: Globale Zeitleiste
CI / pruefen (push) Canceled after 0s
2026-08-18 01:12:55 +00:00
florian.wilde 9cdd51fba8 Globale Zeitleiste ueber alle Projekte
CI / pruefen (pull_request) Canceled after 0s
Eine Seite /verlauf, die den Aktivitaetsverlauf fortlaufend ueber alle
Projekte zeigt - ohne Wochengrenze, nach Kalendertagen gegliedert, mit
Nachladen aelterer Eintraege. Erste Seite serverseitig, alles Weitere ueber
/api/activity.

Geblaettert wird per Cursor ueber (createdAt, id), nicht per skip. Der
Verlauf schreibt mehrere Eintraege im selben Augenblick - beim Uebernehmen
von Issues entstehen zehn Zeilen mit identischem Zeitstempel. Ein Offset
ueber eine solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal,
und zwar lautlos. Der zweite Zweig der Bedingung (gleicher Zeitpunkt,
kleinere ID) faengt genau das ab.

Geprueft gegen 144 Eintraege mit absichtlichen Bloecken gleicher Zeitstempel:
15 Seiten, 144 Zeilen, keine Dublette, streng absteigend.

Die Bedingung selbst steht als pruefbare Funktion in lib/timeline, samt
Deckelung der Seitengroesse und Abweisen unbrauchbarer Cursor - eine ID aus
der Abfragezeichenkette geht nicht ungeprueft in die Abfrage.

Damit Rueckblick und Zeitleiste wirklich gleich aussehen, sind Zeile und
Tagesgruppen nach components/ActivityRow gewandert. Bewusst ohne "use
client" und ohne Hooks, damit beides sowohl aus der Server-Komponente
(Rueckblick) als auch aus der Client-Komponente (Zeitleiste) heraus
benutzbar ist. Die Buendelung im Browser ist unbedenklich, weil
tagesschluessel eine feste Zeitzone benutzt - Server und Browser kommen auf
denselben Kalendertag.

Erreichbar ueber die Schnellsuche und vom Wochenrueckblick aus.

Geprueft: 9 neue Unit-Tests zur Cursor-Logik, ein E2E-Test, der die ganze
Liste in Dreierseiten durchblaettert und gegen einen einzelnen grossen
Aufruf haelt, ein E2E-Test fuer die Seite, axe-core auf 375px und 1280px
je vor und nach dem Nachladen ohne Beanstandung.

Refs #3
2026-08-18 01:54:50 +02:00
florian.wilde 5713facc75 Merge #2: Wochenrückblick
CI / pruefen (push) Canceled after 0s
2026-08-17 23:27:19 +00:00
florian.wilde 52f2d0ce8e Wochenrueckblick ueber alle Projekte
CI / pruefen (pull_request) Canceled after 0s
Eine Seite, die aus dem vorhandenen Aktivitaetsverlauf zusammenfasst, was in
einer Woche ueber alle Projekte hinweg passiert ist: Kennzahlen oben, darunter
die Vorgaenge nach Kalendertagen gebuendelt, neuester Tag zuerst. Blaettern in
frueher Wochen ueber ?woche=-n.

Der Verlauf kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als erste Bedingung.

Zwei Dinge, die sonst still falsch werden:

- **Wochengrenzen** stehen als pruefbare Funktion in lib/review, nicht in der
  Seite. Gerechnet wird ueber Berliner Kalendertage, nicht ueber "sieben mal
  24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
  Stunden. Und im Container laeuft die Anwendung nach UTC, ein Eintrag von
  Sonntagabend fiele sonst in die falsche Woche.
- **"Aufgaben erledigt"** wird am meta-Feld abgelesen (to: "DONE") und nicht
  am Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
  Formulierung; eine Zaehlung daran wuerde beim naechsten umformulierten
  Einzeiler unbemerkt kippen. Eintraege ohne meta zaehlen nicht mit - lieber
  untertreiben als raten.

Damit completeTaskFromIssue dieselbe Grundlage liefert, schreibt es jetzt
ebenfalls from/to ins meta, wie es das Verschieben von Hand schon tut.

Die Zuordnung Symbol/Farbe je Eintragsart lag bisher in ActivityFeed und wird
jetzt von zwei Stellen gebraucht - herausgezogen nach components/activityKinds,
sonst laufen die Kopien beim naechsten neuen Eintragstyp auseinander.

Erreichbar ueber die Schnellsuche und einen Verweis vom Dashboard. Bewusst
kein vierter Punkt in der Kopfleiste - die ist auf dem Telefon schon eng.

Geprueft: 20 neue Unit-Tests (Wochengrenzen inklusive beider Zeitumstellungen,
Jahreswechsel, Zaehlung), ein E2E-Test ueber Aufruf und Blaettern, axe-core auf
375px und 1280px ueber drei Zustaende - auch die leere Woche - ohne
Beanstandung, kein seitlicher Ueberlauf.

Refs #2
2026-08-18 00:55:02 +02:00
florian.wildeandClaude Opus 5 7b60667633 feat(sync): geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab
CI / pruefen (push) Canceled after 0s
Behebt florian.wilde/nebula#1.

Der Rueckweg "Issue geschlossen -> Aufgabe erledigt" gab es seit v1.7.0
schon, aber nur ueber den Webhook. Ist keiner eingerichtet - und am
Repository war keiner eingetragen -, blieb er ganz aus. Damit hing die
Funktion an einer Voraussetzung, die man ihr von aussen nicht ansieht.

Der Abgleich fragt jetzt den Zustand verknuepfter Issues nach und hakt ab,
was in Git geschlossen ist. Beides ergaenzt sich: der Webhook wirkt sofort,
der Abgleich verlaesslich.

Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen
fuehrt, werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht - die
Liste ist gedeckelt, aus dem Fehlen einer Nummer folgt nichts. Deshalb ein
Deckel von 25 Nachfragen je Durchgang.

**Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
Nebula aus fehlgeschlagen ist; ein symmetrischer Abgleich wuerde dann eine
bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
Webhook vorbehalten, der ein echtes "reopened" sieht. Ein geloeschtes Issue
ist ebenfalls kein erledigtes.

Nebenbei eine Unstimmigkeit aus v1.7.0 geradegezogen: der Webhook setzte nur
den Status und liess aus, was die Oberflaeche tut - naechste Fassung einer
wiederkehrenden Aufgabe, Eintrag im Verlauf. Beide Wege gehen jetzt durch
dieselbe Funktion.

Geprueft: 232 Unit-Tests, 31 E2E-Tests. Der neue E2E-Test laeuft als
einziger wirklich nach draussen - er haengt eine Aufgabe an ein seit Jahren
geschlossenes Issue und prueft, dass der Abgleich sie abhakt. Ist GitHub
nicht erreichbar, ueberspringt er sich statt rot zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:20:53 +02:00
florian.wildeandClaude Opus 5 a122187feb feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
CI / pruefen (push) Canceled after 0s
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session
Management und Password Storage. Das meiste stand schon gut da – Token mit
256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder
Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf
Punkte blieben.

**Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat
Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem
2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen
Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt
2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt:
Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel.

**Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer
immer auf den alten Parametern – anheben laesst er sich nur dort, wo das
Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p,
nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu
gehasht werden.

**Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort-
pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423),
deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401).
Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das
Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten
verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer
zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst
hingenommen.

**Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein
abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde.
ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS
einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist –
bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt.

**Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-".
Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist –
eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides
kostet einmalig eine neue Anmeldung.

Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und
Passwoerter, die den Benutzernamen enthalten, werden abgelehnt.
Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab.

Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern)
und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes
Konto gegen die auf ein erfundenes haelt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:38:05 +02:00
florian.wildeandClaude Opus 5 1fe41ac2a5 fix(ui): Projektseite passt wieder in die Fensterbreite
CI / pruefen (push) Canceled after 0s
Auf dem Telefon war die Projektseite doppelt so breit wie der Bildschirm:
die Kopfleiste endete auf halber Strecke, "Jetzt synchronisieren" und der
Notiz-Knopf lagen ausserhalb.

Ursache sind die beiden Rasterspalten. Ein Rasterkind hat "min-width: auto"
und weigert sich damit, unter die Inhaltsbreite seines breitesten Kindes zu
schrumpfen. Breitestes Kind ist die Heatmap mit ueber 700px – sie zog die
Spalte auf und mit ihr die ganze Seite. Das overflow-x-auto um sie herum
kam gar nicht zum Zug, weil der Elter ihm die volle Breite zugestand.

Mit min-w-0 an beiden Spalten greift es wieder: gemessen auf 390px liegen
scrollWidth und clientWidth jetzt gleichauf, vorher 811 zu 390.

Warum das beim Umbau der mobilen Ansicht nicht auffiel: die Messung lief
gegen ein Projekt ohne Repository, ohne Snapshot und ohne README. Ohne
Heatmap gibt es nichts, was die Spalte aufziehen koennte. Nachgestellt
wurde das jetzt mit einem vollstaendigen Projekt – Snapshot, README mit
Codeblock und Tabelle, Notizen, Aufgaben.

Ein Regressionstest dafuer fehlt noch: die E2E-Tests legen Projekte ueber
die API an und kommen ohne echten Abgleich zu keinem Snapshot, also auch
zu keiner Heatmap. Ein Test ohne sie wuerde die Sicherheit vortaeuschen,
ohne den Fall zu treffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 07:32:04 +02:00
florian.wildeandClaude Opus 5 c270038edd feat(git): Webhooks fuer Push und Issues, v1.7.0
CI / pruefen (push) Canceled after 0s
Ein Push aktualisiert den Stand sofort, statt bis zum naechsten Durchlauf
des Zeitgebers zu warten; ein geschlossenes Issue hakt seine Aufgabe ab.
Der Zeitplan bleibt als Rueckfalllosung.

Der Endpunkt ist der einzige ohne Anmeldung, der schreibt – entsprechend
eng gefasst:

- Ausgewiesen wird sich mit einer Signatur ueber den Rumpf. GitHub und
  Gitea rechnen HMAC-SHA256, GitLab schickt nur den Token im Klartext;
  dessen Verfahren ist schwaecher, mehr gibt die Plattform nicht her.
  Verglichen wird durchweg zeitkonstant.
- Geprueft wird gegen den **rohen** Rumpf. Durch JSON.parse und
  JSON.stringify gedreht kaeme eine andere Zeichenfolge heraus und die
  Signatur passte nie.
- Der Rumpf ist nie Quelle fuer Inhalte. Wer das Geheimnis kennt, kann eine
  Aufgabe auf- oder zumachen und einen Abgleich ausloesen – keine
  beliebigen Daten einschleusen.
- Ratenbegrenzung je Repository, Deckel auf die Rumpfgroesse, und ein
  unbekanntes Repository antwortet wie ein nicht eingerichtetes: 404,
  damit die ID nicht bestaetigt wird.
- Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler
  brachte das Git-System zum Wiederholen und irgendwann zum Abschalten.

Das Geheimnis liegt verschluesselt am Repository, wie die Zugangstoken, und
ist genau einmal zu sehen. Damit es nicht doch nach draussen wandert, laedt
repositoryInclude die Repositories nicht mehr per include, sondern ueber
eine ausdrueckliche Feldliste: ein include haette das neue Feld still in
jede Projektantwort und jede Seite mitgenommen. Ob ein Haken eingerichtet
ist, holt die Projektseite getrennt.

Die Migration legt zwei nullbare Spalten an, mehr nicht; der Gegenlauf von
migrate diff kommt leer heraus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:35:29 +02:00
florian.wildeandClaude Opus 5 e1d435f10a feat(tasks): Aufgabe als Git-Issue anlegen, Zustand wandert mit
Die Zuordnung stand im Schema laengst – sourceIssue, sourceUrl und
sourceRepositoryId samt Eindeutigkeit je Repository, gefuellt von der
Uebernahme aus Issues seit v0.9.0. Es fehlte die Gegenrichtung.

Ein Knopf im Aufgabendialog legt das Issue an; Nummer und Adresse landen in
denselben Feldern, der Dublettenschutz gilt also fuer beide Wege. In den
Rumpf kommt ein Rueckverweis auf das Projekt.

Ausdruecklich per Knopf und nie nebenbei: was in ein fremdes System
geschrieben wird, soll jemand gewollt haben. Ist die Aufgabe erst
verknuepft, wandert der Zustand allerdings mit – erledigt schliesst das
Issue, ein Zurueckziehen oeffnet es wieder. Fehlertolerant: ein nicht
erreichbares Git-System darf das Abhaken nicht verhindern.

Die Zustaende passen nicht aufeinander, und das bleibt so. Nebula kennt
vier, ein Issue zwei; nach aussen geht nur die Grenze um ERLEDIGT. Ein
Rundlauf verliert damit IN ARBEIT und BLOCKIERT. Labels waeren die
Alternative, wuerden aber den Issue-Tracker mit Zustaenden fuellen, die
dort niemand pflegt.

Kein Widerspruch zu "keine eigenen Git-Operationen": Issues laufen ueber
die HTTP-API der Provider. Dafuer gibt es jetzt sendJson neben getJson –
durch dieselbe SSRF-Schleuse, aber ohne Umleitungen zu folgen. Ein 307 auf
eine interne Adresse naehme den Rumpf und den Token im Header mit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:35:11 +02:00
florian.wildeandClaude Opus 5 6a7365946b fix(ui): Dialoge weichen der Bildschirmtastatur aus
Auf dem Telefon verdeckte die Tastatur den halben Dialog – bei der
Schnellerfassung genau das Eingabefeld, um das es geht.

Ursache ist der Unterschied zwischen Layout- und Sichtfenster: die Tastatur
schiebt sich darueber, ohne dass sich fuer die Seite etwas aendert.
"position: fixed" und "100vh" beziehen sich weiter auf das volle Fenster,
ein mittig stehender Dialog liegt damit zur Haelfte darunter.

Der Dialog richtet sich jetzt nach window.visualViewport – Hoehe und oberer
Rand kommen von dort und werden bei jeder Aenderung nachgefuehrt. Weil der
Behaelter scrollbar ist, holt der Browser das fokussierte Feld dann von
selbst ins Bild. Das gilt fuer alle Dialoge, nicht nur die Schnellerfassung.

Dazu, weil es beim Nachsehen auffiel: Aufgaben lassen sich in der
Uebersicht direkt bearbeiten. Bisher fuehrte von dort nur der Weg ins
Projekt, und wer eine Faelligkeit aendern wollte, musste die Aufgabe im
Brett wiederfinden. Der Dialog nimmt dafuer eine schlankere Form entgegen
als den vollstaendigen Prisma-Datensatz.

Geprueft mit einem nachgestellten Sichtfenster: Eingabefeld und Absende-
knopf liegen bei 375x667 und aufgeklappter Tastatur vollstaendig im freien
Bereich. Vorher lagen beide darunter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:34:56 +02:00
florian.wildeandClaude Opus 5 c9202fb65e chore(release): v1.6.0
CI / pruefen (push) Canceled after 0s
Der Server hat den neuen Stand ausgeliefert und in der Fusszeile weiter
"v1.5.0" angezeigt: Listenansicht, Schnellerfassung und die Aufgabenseite
waren drin, die Versionsnummer nicht.

Der Test in changelog.test.ts prueft, dass die oberste Fassung im Verlauf
zur package.json passt - er greift aber nicht, wenn beide gemeinsam
zurueckhaengen. Genau das war hier der Fall.

Der Verlauf wird in der Anwendung angezeigt (Knopf an der Versionsnummer in
der Fusszeile), deshalb steht dort jetzt, was sich geaendert hat: die
mobile Ansicht, die vierte Ansicht im Board, die Schnellerfassung, die
Aufgaben ueber alle Projekte, 16px in Eingabefeldern gegen den iOS-Zoom,
die fehlenden Beschriftungen der Symbolknoepfe und das beschnittene
Tag-Formular.

In der Roadmap ist v1.6.0 als erledigt eingetragen. Offen bleibt daraus
nur der dritte Teil, die persoenlichen API-Token - die sind
sicherheitsrelevant und bekommen einen eigenen Durchgang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:56:03 +02:00
florian.wildeandClaude Opus 5 5ae1b6ed06 feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte
CI / pruefen (push) Canceled after 0s
Zwei Wege, die zusammengehoeren: etwas festhalten, ohne den Rechner
aufzuklappen, und danach einen Ort haben, an dem alles zusammenlaeuft.

**Schnellerfassung.** Ein Blitzsymbol in der Kopfleiste, auf jeder Seite
erreichbar. Ein Feld, Enter, fertig - als Idee entsteht ein Projekt im
Status IDEA, als Aufgabe eine Aufgabe im gewaehlten Projekt. Beide Schemas
kommen mit einem einzigen Feld aus, an der API war deshalb nichts zu
aendern.

Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
Idee notiert, hat meistens gleich die naechste; ihn jedes Mal neu zu oeffnen
waere genau die Huerde, die hier wegsoll. Das zuletzt gewaehlte Projekt
wird gemerkt.

Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten
rechts - dort waere er auf schmalen Anzeigen mit der Auswahlleiste
kollidiert, die beim Markieren von Projekten einfaehrt.

**Aufgabenuebersicht.** /tasks sammelt jede Aufgabe aus jedem Projekt.
Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
Abhaken geht direkt aus der Liste; den Rest - Zeitpunkt, abgeleiteter
Fortschritt, naechste Fassung wiederkehrender Aufgaben - erledigt der
bestehende Endpunkt.

Zwei Entscheidungen, die im Code begruendet sind:

- Gerechnet wird auf Kalendertage in fester Zeitzone, nicht auf Stunden und
  nicht nach Serverzeit. Eine um 9 Uhr faellige Aufgabe ist um 17 Uhr nicht
  ueberfaellig, sondern immer noch "heute". Und im Container laeuft die
  Anwendung nach UTC - ohne feste Zeitzone kippte die Einteilung abends um
  einen Tag. Das Fach bestimmt der Server, sonst liefen Server-Render und
  Hydration auseinander.
- Bewusst nicht per Ziehen sortierbar. Die Position gilt innerhalb ihrer
  Spalte in ihrem Projekt und waere hier ohne Bedeutung; eine Reihenfolge
  anzubieten, die nirgends ankommt, waere ein leeres Versprechen.

Die Aufgabe kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als einzige und erste Bedingung.

Nebenbei: der Untertitel in der Kopfleiste brach mit dem dritten
Navigationspunkt auf 375px mitten im Wort ab und bleibt unterhalb von sm
jetzt weg.

Geprueft: 200 Unit-Tests (davon 7 neue zu dueBucket, inklusive des
UTC-Falls), 27 E2E-Tests mit zwei neuen Wegen, axe-core auf 375px ueber
Uebersicht und beide Zustaende der Schnellerfassung ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:33:16 +02:00
florian.wildeandClaude Opus 5 fcc903601a docs(roadmap): v1.6.0 und v1.7.0 aufgenommen
Zwei Vorhaben, die sich aus der Durchsicht der mobilen Ansicht ergeben haben.

v1.6.0 buendelt Schnellerfassung, eine projektuebergreifende Aufgabenseite
und die persoenlichen API-Token: erfassen am Telefon, ueberblicken an einer
Stelle, ansprechen per Shortcut. Die beiden Fallstricke der Aufgabenseite
stehen dabei: Task hat kein ownerId, und position gilt nur je Projekt.

v1.7.0 verknuepft Aufgaben mit Git-Issues. Die Zuordnung steht im Schema
laengst (sourceIssue, sourceUrl, sourceRepositoryId); es fehlt nur die
Gegenrichtung zur bestehenden Uebernahme. Bewusst ueber die HTTP-API der
Provider, nicht ueber eigene Git-Operationen - der Ausschluss unter
"Bewusst nicht" bleibt also unangetastet.

Ausdruecklich festgehalten, was NICHT kommt: eine echte beidseitige
Synchronisation. Die Zustaende passen ohnehin nicht aufeinander - ein Issue
kennt nur offen und geschlossen, DOING und BLOCKED gehen im Rundlauf
verloren. Das wird hingenommen, statt den Issue-Tracker mit Labels zu
fuellen, die dort niemand pflegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:32:53 +02:00
florian.wildeandClaude Opus 5 f3c3383231 feat(board): Listenansicht, und die mobile Ansicht laeuft nicht mehr aus dem Bild
CI / pruefen (push) Canceled after 0s
Auf 375px war das Dashboard 40px breiter als der Bildschirm. Ursache war die
Steuerleiste: Suchfeld, Sortierung, Ansichtsumschalter und "Neues Projekt"
lagen in einer Flex-Zeile ohne Umbruch, und weil das Sortierfeld ein
min-width mitbringt, wurde der Knopf am Ende schlicht hinausgeschoben. Das
overflow-x: hidden am body hat ihn dann abgeschnitten statt erreichbar zu
lassen. Dasselbe Muster im Projektkopf, wo "Loeschen" ausserhalb lag.

Die Zeilen brechen jetzt um, der Ansichtsumschalter steht auf schmalen
Anzeigen ueber die volle Breite. Gemessen ueber alle Seiten und beide
Testbreiten: scrollWidth == clientWidth.

Weil der Umschalter ohnehin umgebaut wurde, kommt die vierte Ansicht gleich
mit: eine Zeile je Projekt statt einer Karte. Auf dem Telefon ist das der
eigentliche Gewinn — eine Karte braucht rund 260px Hoehe und steht dort
zwangslaeufig allein in der Zeile, fuenf Projekte fuellen also zwei
Bildschirme. Als Liste passen sie auf einen, und weil alle Zeilen gleich
gebaut sind, liegen Name, Fortschritt und Status untereinander. Was nicht
mehr in die Breite passt, faellt der Reihe nach weg, statt sie zu sprengen.

Dazu die Kleinigkeiten, die auf dem Telefon dranhaengen:

- .field bekommt auf Beruehrungsgeraeten 16px. Darunter zoomt iOS Safari
  beim Hineintippen die Seite heran und kehrt nicht von selbst zurueck.
- .btn mindestens 44px hoch, der Schieberegler einen 24px-Griff.
- Der Popover klemmt seine Breite ans Fenster. Unter 280px lieferte die
  Rechnung eine negative linke Kante, das Menue stand halb ausserhalb.
- Das Tag-Formular der Auswahlleiste war 16px breiter als sein Popover und
  wurde von dessen overflow-hidden beschnitten – auf jeder Breite.
- Die Heatmap scrollt wieder seitlich, statt sich zu stauchen.
- Die Kennzahlen stehen zweispaltig; einspaltig fuellten die fuenf Kacheln
  den ganzen ersten Bildschirm, bevor das erste Projekt kam.
- viewportFit und die safe-area-Abstaende, damit im installierten Betrieb
  nichts unter Statusleiste und Home-Anzeige verschwindet.
- Die beiden Verweise in der Kopfleiste und "Neues Projekt" tragen ein
  aria-label. Unterhalb von sm sind sie reine Symbole und hiessen fuer einen
  Screenreader gar nichts; axe-core meldete das als link-name.

axe-core laeuft auf 375px ueber alle vier Ansichten, den Dialog und die
Projektseite jetzt ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:58:27 +02:00
florian.wildeandClaude Opus 5 86f1fbeb50 fix(scripts): Ausfuehrbarkeitsbit im Git setzen
CI / pruefen (push) Has been cancelled
Die Shell-Skripte lagen als 100644 im Index. Nach einem frischen Clone auf
dem Server scheiterte damit jedes "./scripts/setup.sh" und "./scripts/update.sh"
an fehlender Berechtigung; wer sich mit chmod +x behalf, hatte danach ein
schmutziges Arbeitsverzeichnis, und genau daran brach update.sh ab.

Unter Windows faellt das nicht auf, weil core.filemode dort auf false steht --
das Bit muss deshalb mit "git update-index --chmod=+x" gesetzt werden. Auch
docker/entrypoint.sh bekommt es; der Dockerfile-Aufruf chmod +x bleibt als
zweiter Riegel bestehen.

Ausserdem sagt update.sh jetzt, welche Dateien geaendert sind, statt nur zu
melden, dass etwas nicht sauber ist. Wer die Meldung liest, soll nicht erst
"git status" nachschlagen muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:13:56 +02:00
florian.wildeandClaude Opus 5 fc54c73791 fix(docker): die Datenbank veroeffentlicht ueberhaupt keinen Host-Port mehr
CI / pruefen (push) Has been cancelled
Der letzte Anlauf hat das Problem nicht geloest. Die Freigabe stand weiter in
der Basisdatei, nur mit anderem Vorgabewert -- und Vorgabewerte greifen nicht,
wo die Variable schon gesetzt ist: in jeder .env, die aus der bisherigen
.env.example entstanden ist, steht POSTGRES_PORT="5432". Postgres band also
unveraendert auf 0.0.0.0 und kollidierte mit anderen Stacks auf demselben
Host; der Container kam gar nicht erst hoch. Das Overlay half nicht, weil es
erst greift, wenn man es dazunimmt.

Jetzt hat der db-Dienst keinen ports-Block mehr. Gebraucht wird er nur, wenn
die Anwendung ausserhalb des Containers laeuft, und dafuer gibt es
docker-compose.dev.yml (npm run docker:db). Damit ist der sichere Fall die
Vorgabe und der Sonderfall die Ausnahme, nicht umgekehrt.

Der App-Port bindet aus demselben Grund auf 127.0.0.1: Docker traegt seine
Freigaben direkt in iptables ein und umgeht dabei ufw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:46:24 +02:00
florian.wildeandClaude Opus 5 d311fe48c0 fix(docker): keine Ports auf 0.0.0.0 und ein Overlay ohne Freigaben
CI / pruefen (push) Has been cancelled
Der Datenbankport wurde in beiden Betriebsarten auf 0.0.0.0 gelegt, obwohl
ihn nur die Entwicklung braucht: im Container spricht die Anwendung "db:5432"
ueber das Compose-Netz an, und backup.sh geht ueber docker exec. Auf einem
Server lauschte Postgres damit im Netz, geschuetzt allein durch das Passwort
aus der .env. Die Anleitung riet zwar zu 127.0.0.1, aber als Handgriff statt
als Voreinstellung -- die falsche Richtung. Datenbank und Adminer binden
jetzt von Haus aus auf 127.0.0.1.

Liegen mehrere Stacks auf einem Host und regelt ein gemeinsamer Reverse Proxy
den Zugang, braucht Nebula gar keinen Host-Port. docker-compose.server.yml
nimmt beide Freigaben zurueck und haengt die Anwendung in ein geteiltes Netz.
Das braucht !reset: Compose fuehrt "ports" beim Zusammenfuehren anhaengend
statt ersetzend zusammen, eine leere Liste im Override wuerde also nichts
loeschen.

update.sh las die Adresse aus APP_PORT und haette in diesem Modus einen Port
gemeldet, den es nicht gibt. Es fragt jetzt Docker und sagt sonst, dass nur
das interne Netz erreichbar ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:51 +02:00
florian.wildeandClaude Opus 5 6808d4d34a fix(scripts): Einrichtung und Update ohne Node
CI / pruefen (push) Has been cancelled
Ein reiner Docker-Host hat kein Node installiert. Die Anleitung verlangte
trotzdem "node scripts/setup.mjs", und update.sh las die Versionsnummer mit
"node -p" - beides waere dort gescheitert.

scripts/setup.sh macht dasselbe wie die .mjs-Fassung in reinem POSIX-Shell:
.env aus der Vorlage anlegen, ein APP_SECRET wuerfeln (openssl, sonst
/dev/urandom) und die Rechte auf 600 setzen. Ersetzt wird zeilenweise statt
mit einem sed-Ausdruck, weil base64 Schraegstriche enthaelt. update.sh liest
die Version jetzt selbst aus der package.json.

Dabei fielen zwei Fehler auf, die mein eigener Rat verursacht hat. Die
Anleitung empfiehlt APP_PORT="127.0.0.1:3000", damit die Anwendung nur hinter
dem Reverse Proxy erreichbar ist - die Gesundheitspruefung baute daraus aber
"http://127.0.0.1:127.0.0.1:3000". Und curl ist auf einem Docker-Host nicht
garantiert. Gefragt wird deshalb jetzt der Container selbst ueber seinen
HEALTHCHECK; curl liefert nur noch die Antwort zum Anzeigen, wenn es da ist.

Am Ende nannte das Skript den Sicherungspfad mit Anfuehrungszeichen - so
liess er sich nicht kopieren.

Nachgewiesen: dreimal gegen die laufende Instanz durchgelaufen, jedes Mal
mit Sicherung, Bau, Neustart und gesunder Antwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:26:46 +02:00
florian.wildeandClaude Opus 5 47245152a2 docs: Anleitung fuer den Serverbetrieb und Update-Skript
CI / pruefen (push) Has been cancelled
DEPLOYMENT.md fuehrt Schritt fuer Schritt vom leeren Server zur Instanz
hinter TLS: Docker, Konfiguration, Reverse Proxy mit Caddy oder nginx,
Firewall, Sicherungen per Cron. Dazu, was an Haertung schon eingebaut ist -
und was der Betreiber selbst beisteuern muss.

scripts/update.sh macht die Schritte in der richtigen Reihenfolge: pruefen,
holen, sichern, bauen, starten, Gesundheit pruefen. Erst bauen und dann
umschalten, damit ein gescheiterter Bau die laufende Fassung nicht
mitnimmt; die Sicherung liegt vor jeder Schemaaenderung. Antwortet
/api/health nach einer Minute nicht, zeigt es die letzten Logzeilen und
nennt den Weg zurueck.

Es weigert sich zu laufen, solange APP_SECRET leer oder noch der
Entwicklungswert ist - mit diesem Wert liegen die Git-Tokens faktisch offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 d7176339b8 test: Bereinigung des Markdowns festhalten, Ratenbegrenzung nachziehen
Elf Tests halten fest, was nicht durchkommen darf: script-Elemente,
Ereignis-Attribute aus rohem HTML, javascript: als Link und als Bildquelle,
data:text/html. Dazu die Aufloesung relativer Verweise und der Fall
"Bild im Link", der kein a im a erzeugen darf.

Gerendert wird serverseitig - fuer die Frage nach dem erzeugten HTML
braucht es kein DOM. Damit .tsx ueberhaupt laeuft, muss Vitest das JSX
selbst uebersetzen; die tsconfig setzt jsx auf "preserve", weil sonst Next
zustaendig ist. Vite nimmt inzwischen oxc statt esbuild, die esbuild-Option
wuerde stillschweigend ignoriert.

Umsortieren im Brett und das Anlegen von Vorlagen hatten als einzige
schreibende Endpunkte keine Ratenbegrenzung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 df94646371 fix(security): Bildoptimierer abschalten
/_next/image war ohne Anmeldung erreichbar und antwortete mit 200. Die
Middleware nimmt den Pfad aus, weil dort sonst nur Rechenzeit verbraucht
wuerde - gedacht war das fuer ausgelieferte Dateien, nicht fuer einen
Endpunkt, der Bilder verarbeitet.

Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
regelmaessig Schwachstellen gibt; derzeit sind vier offen (CVE-2026-33327,
-33328, -35590, -35591). Ausnutzbar waere das hier nur mit einer Datei, die
schon auf dem Server liegt - hochladen kann man nichts. Aber Nebula benutzt
next/image ohnehin nirgends: eine Angriffsflaeche ohne Gegenwert.

Der Optimierer ist deshalb abgeschaltet, und der Pfad ist nicht mehr von der
Zugangsschranke ausgenommen. Ein Test haelt fest, dass er ohne Anmeldung
nicht mehr mit 200 antwortet.

Die Advisories zu postcss und sharp bleiben in npm audit stehen: Next pinnt
beide fest, auch in 16.2.12 - ein Versionssprung wuerde daran nichts
aendern. postcss laeuft nur zur Bauzeit ueber eigenes CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 27f51b6be8 fix(auth): Einmalkennwoerter nicht zweimal annehmen
Ein TOTP-Code blieb rund 90 Sekunden gueltig und liess sich darin beliebig
oft verwenden. RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich das
Gegenteil: derselbe Code darf kein zweites Mal angenommen werden.

Ohne diese Sperre kann ein abgefangener Code - ueber die Schulter gesehen,
von einer weiterleitenden Phishing-Seite, von Schadsoftware vom Bildschirm
gelesen - die restliche Gueltigkeitsdauer ueber wiederverwendet werden.

verifyTotp gibt dafuer jetzt ueber matchTotp das Zeitfenster zurueck, das
gepasst hat; angenommen wird nur, was neuer ist als das zuletzt eingeloeste.
Der Vergleich bleibt dabei in konstanter Zeit - es wird weiterhin nicht
fruehzeitig abgebrochen.

Auch die Aktivierung verbrennt ihr Fenster: der Code, der eben
scharfgeschaltet hat, soll nicht Sekunden spaeter noch zum Anmelden taugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 72e5eb43c4 test: README-Hilfen abdecken, Doku, Version 1.4.0
CI / pruefen (push) Has been cancelled
Elf Tests: welche Dateinamen als README gelten, dass Markdown Vorrang hat,
dass an einer Zeilengrenze gekuerzt wird und dass base64 mit Umlauten und
Zeilenumbruechen richtig ankommt.

Im Browser gegen vercel/swr geprueft, dazu ein erfundenes README fuer die
relativen Links, die swr nicht hergibt: aufgeloeste Pfade, unveraenderte
absolute Links, Anker bleiben Anker, kein img im Dokument und kein a im a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 e97bd43b23 feat(ui): README auf der Projektseite anzeigen
Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich
vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast.

Relative Links werden gegen das Verzeichnis der Datei im Repository
aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md"
und nicht die volle Adresse.

Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die
Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie
dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger
Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und
Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter
Tausch.

Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den
Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel,
und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen
auseinander - der erste Versuch scheiterte prompt an einem
Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 f8e6c66876 feat(git): README des Repositories holen
Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
Projekt eigentlich ging.

GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf
genuegt. GitLab und Gitea kennen keinen; dort wird erst das
Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt
werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung
egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann.

Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem
Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert
werden.

Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst
bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer
und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus.

Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann
trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:18 +02:00
florian.wildeandClaude Opus 5 01884f4747 test: wiederkehrende Aufgaben abdecken, Doku, Version 1.3.0
CI / pruefen (push) Has been cancelled
Sieben Tests fuer die Terminlogik: Rechnen vom Faelligkeitsdatum,
Vorruecken bei langem Liegenbleiben, der Monatsuebergang ueber kurze
Monate, und dass die Schleife auch bei einem Datum von 1970 endet.

Dazu ein Ende-zu-Ende-Test ueber beide Wege nach ERLEDIGT und den Nachweis,
dass ein erneutes Speichern keine zweite Fassung erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 04a07bd36c fix(ui): Kontrast gedaempfter Texte auf WCAG AA bringen
Eine Durchsicht mit axe-core ueber zehn Seitenzustaende - Dialoge einzeln,
sie stehen geschlossen nicht im DOM - fand 26 Verstoesse, alle derselben
Art: slate-500 kommt auf dem sehr dunklen Untergrund nur auf 3,8 zu 1,
slate-600 sogar auf 2,5. Verlangt sind 4,5 fuer normalen Text, und beide
Stufen werden reichlich fuer Hinweise und Nebenangaben benutzt.

Statt an hundert Stellen die Klasse zu tauschen, sind die beiden Stufen im
Theme aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar, der Rest des
Entwurfs unangetastet. Geprueft gegen alle drei vorkommenden Untergruende;
der schlechteste Wert zaehlt.

Damit stimmt auch, was FEATURES.md ueber Barrierefreiheit behauptet - bisher
stand die Behauptung ungeprueft da.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 a3093dc68a feat(tasks): wiederkehrende Aufgaben
Taeglich, woechentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe
nach ERLEDIGT, entsteht die naechste von selbst. Die erledigte bleibt
stehen - sonst ginge die Historie verloren.

Gerechnet wird vom Faelligkeitsdatum, nicht vom Abhaken: eine woechentliche
Aufgabe bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch
erledigt wurde. Lange Liegengebliebenes rutscht so weit vor, bis der Termin
in der Zukunft liegt; eine Aufgabe, die schon bei ihrer Entstehung
ueberfaellig ist, hilft niemandem.

Beim Monatsintervall bleibt der Tag im Monat erhalten. setMonth allein
rollt ueber: der 31. Januar wuerde zum 3. Maerz statt zum 28. Februar.

Ausgeloest wird an beiden Stellen, an denen eine Aufgabe erledigt werden
kann - im Dialog und durch Ziehen ins Brett. Nur der Uebergang zaehlt: eine
bereits erledigte Aufgabe erneut zu speichern erzeugt keine zweite Fassung.

Bewusst grob gehalten. Feinere Regeln wie "jeden zweiten Dienstag im Monat"
braucht ein Projektdashboard nicht, und sie waeren ohne Kalenderansicht
kaum nachvollziehbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:39 +02:00
florian.wildeandClaude Opus 5 52cfb075e3 test: Heatmap und Vorlagen abdecken, Doku, Version 1.2.0
CI / pruefen (push) Has been cancelled
Elf Tests fuer die Seitenlogik des Kalenders: Deckel, Abbruch bei
unvollstaendiger Seite, Commits mit Datum in der Zukunft, und dass der
Zeitraum beim aeltesten bekannten Commit beginnt statt vor einem Jahr.

Dazu ein Ende-zu-Ende-Test fuer Vorlagen: sichern, anwenden, Datentrennung
gegen ein zweites Konto, und dass die Vorlage das Loeschen ihres
Ursprungsprojekts ueberlebt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 57c50cfb7f feat(projects): Projektvorlagen
Ein Projekt, das sich bewaehrt hat, laesst sich im Projektkopf als Vorlage
sichern: Status, Prioritaet, Farbe, Tags, Notizen und offene Aufgaben. Beim
Anlegen steht sie oben im Dialog zur Auswahl.

Benutzereigen statt fest eingebaut. Welche Tags und welche Startaufgaben
sinnvoll sind, weiss nur, wer damit arbeitet - eine mitgelieferte Liste
waere geraten.

Zwei Dinge gehen bewusst nicht mit. Erledigte Aufgaben nicht: eine Vorlage
beschreibt, was zu tun ist, nicht was einmal getan wurde. Und Repositories
nicht: eine Vorlage, die jedes neue Projekt an dasselbe Repository haengt,
waere eine Falle.

Die Stammdaten uebernimmt der Dialog sichtbar in die Felder, damit man sie
vor dem Speichern noch aendern kann; bereits Getipptes bleibt stehen.
Notizen und Aufgaben legt erst der Server an - im Dialog liessen sie sich
nicht darstellen, und wer die Vorlage wieder abwaehlt, soll sie nicht
mitgeschleppt bekommen.

templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus
einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 ca6181b58f feat(git): Commit-Heatmap ueber ein Jahr
Ein Feld je Tag auf der Projektseite, Wochen als Spalten, mit Farbskala und
Datum im Tooltip.

Woher die Zahlen kommen, unterscheidet sich je Anbieter. GitHub fuehrt die
Statistik selbst und liefert 52 Wochen mit Tageswerten in einem einzigen
Aufruf. GitLab und Gitea kennen nichts Vergleichbares; dort werden Commits
seitenweise geholt, gedeckelt auf fuenf Seiten - genug fuer die allermeisten
Repositories und wenig genug, um kein fremdes Kontingent zu sprengen.

Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen. Reichen
die Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an und sagt
es dazu. Ein leeres Feld wuerde sonst behaupten, an dem Tag sei nicht
gearbeitet worden, obwohl darueber nichts bekannt ist - genau diesen Fehler
hatte die Verlaufslinie schon einmal.

Geholt wird hoechstens einmal am Tag und getrennt vom uebrigen Abgleich: an
einem Jahresueberblick aendert sich von Stunde zu Stunde nichts Sichtbares,
der Abgleich laeuft aber im Zweifel stuendlich.

Der Kalender benutzt getJson statt getJsonSafe. Waere ein Rate-Limit als
"keine Daten" durchgegangen, haette der gesetzte Zeitstempel die Heatmap
einen ganzen Tag lang unterdrueckt. Jetzt bleibt er bei einem Fehlschlag
ungesetzt, und der naechste Abgleich versucht es erneut - das deckt auch den
Fall ab, dass GitHub die Statistik gerade erst berechnet und mit 202
antwortet.

Nachgewiesen an vercel/swr: 49 Commits an 33 Tagen ueber ein volles Jahr aus
der Statistik, und mit kurzer Historie 90 statt 365 Felder samt Hinweis auf
den Deckel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:19 +02:00
florian.wildeandClaude Opus 5 fce0a90cc3 chore: prisma.config.ts, Tests und Doku, Version 1.1.0
CI / pruefen (push) Has been cancelled
Prisma liest seine Einstellungen aus prisma.config.ts statt aus der
package.json; die Warnung vor Prisma 7 ist damit weg. Der dotenv-Aufruf
darin ist nicht optional: sobald die Datei existiert, laedt Prisma die .env
nicht mehr von selbst, und migrate deploy staende ohne DATABASE_URL da.

Vier neue Ende-zu-Ende-Tests: Reihenfolge und Mehrfachauswahl ueber die API,
Mehrfachauswahl in der Oberflaeche, der Aenderungsverlauf und das Manifest
samt Symbolen ohne Anmeldung. Damit sind es 21.

Der Test zum Ziehen von Aufgaben war gelegentlich rot, ohne dass etwas kaputt
war: das Neuladen brach den noch laufenden Speicheraufruf ab. Er wartet jetzt
auf die Antwort, statt auf eine feste Pause zu hoffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:44 +02:00
florian.wildeandClaude Opus 5 16e8d31277 feat(ui): Aenderungsverlauf hinter der Versionsnummer
Ein Klick auf die Version in der Fusszeile zeigt, was in den bisherigen
Fassungen dazugekommen ist, was besser wurde und welche Fehler behoben sind.
Kein eigener Menuepunkt: gesucht wird so etwas dort, wo die Version steht.

Die Liste steht in lib/changelog.ts und nicht in einer CHANGELOG.md. Sie
wird in der Anwendung angezeigt, und zwei Quellen liefen prompt auseinander
- wie es der Versionsnummer schon einmal passiert ist, die eine Zeit lang in
der Fusszeile und in der package.json verschiedene Werte zeigte. Ein Test
haelt den obersten Eintrag und die package.json seitdem zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 d97b6f650a feat(pwa): als App installierbar
Web-Manifest mit eigenen Symbolen; auf dem Handy laesst sich Nebula zum
Startbildschirm hinzufuegen und laeuft dann ohne Adressleiste.

Das Manifest entsteht wie die uebrigen Metadaten aus einer Funktion, nicht
aus einer Konstanten: nur so wird APP_NAME zur Laufzeit gelesen und das
fertige Docker-Image bleibt umbenennbar.

Manifest und Symbole muessen an der Zugangsschranke vorbei - der Browser
holt sie, bevor sich jemand angemeldet hat. Sonst landeten sie auf der
Anmeldeseite und die Anwendung waere nicht installierbar.

Die maskierbare Fassung hat mehr Rand: Android schneidet Symbole je nach
Geraet rund, eckig oder als Tropfen zu. Die PNGs sind aus dem vorhandenen
SVG mit Chrome erzeugt - das spart eine Bildbibliothek als Abhaengigkeit.

Beim Ergaenzen des Apple-Symbols ist mir aufgefallen, dass ein eigener
icons-Eintrag den Verweis ersetzt, den Next aus app/icon.svg von selbst
erzeugt: die Seite hatte danach gar kein Symbol mehr und der Browser fiel
auf /favicon.ico zurueck, das es hier nicht gibt. Beide Eintraege stehen
jetzt ausdruecklich da, ein Test prueft den Verweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 49e253f70b feat(board): Mehrfachauswahl fuer Status, Tags und Loeschen
Kaestchen auf jeder Karte, darunter eine Leiste am unteren Rand. Fest
verankert, weil sie bei einer langen Liste oben laengst aus dem Blick
gescrollt waere, waehrend man noch auswaehlt.

Ein eigener Endpunkt statt vieler einzelner PATCHes: eine Transaktion statt
zwanzig Rundgaengen, und der Verlauf bekommt je Projekt genau einen Eintrag
statt eines Schwalls.

Was der Filter ausblendet, faellt aus der Auswahl - sonst traefe eine
Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. Fremde IDs
sammelt der Server wegen der Besitzerbedingung erst gar nicht ein und meldet
sie als uebergangen.

Das Kaestchen ist gedaempft, aber immer sichtbar. Erst beim Ueberfahren
einzublenden waere auf einem Touchgeraet nicht erreichbar - und wer es nie
sieht, weiss nicht, dass es die Auswahl gibt.

Ansicht, Sortierung und Archivschalter ueberdauern nun das Neuladen. Wer sein
Brett von Hand anordnet, will es beim naechsten Aufruf auch sehen. Der
Suchbegriff bleibt aussen vor: eine Suche von gestern wiederherzustellen
waere eher verwirrend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 1400bca047 feat(board): freie Reihenfolge im Projektbrett
Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am
Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das
Projektbrett der gewaehlten Sortierung folgte, war ein Bruch.

Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie
stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend.

Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen
im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett
wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum
setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht,
sonst bestuende der Verlauf bald aus nichts anderem.

Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene
Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang
wird jetzt an allen vier Stellen gesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 5253b0a317 test: mehrere Repositories abdecken, Doku, Version 1.0.0
CI / pruefen (push) Has been cancelled
Drei neue Ende-zu-Ende-Tests: das Verwalten mehrerer Repositories samt
Datentrennung, das Lesen von Exportdateien der Version 1, und die
Uebernahme ohne verknuepftes Repository.

Dabei fiel auf, dass der Uebernahme-Endpunkt ohne Repository mit 404
antwortete, obwohl gar keine Repository-ID angefragt war. Wer nichts angibt,
hat schlicht noch keines verknuepft - das ist kein "nicht gefunden", sondern
409 mit dem Hinweis, eines zu hinterlegen.

Version 1.0.0: der Funktionsumfang ist beisammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:08 +02:00
florian.wildeandClaude Opus 5 b882c870c3 feat(ui): Repositories im Git-Panel verwalten
Eine Leiste mit allen Repositories des Projekts, sobald es mehr als eines
gibt - bei einem einzigen waere eine Leiste mit genau einem Knopf nur
Ballast. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich.

Verknuepfen und Loesen laufen ueber einen eigenen Dialog mit derselben
Repo-Auswahl wie beim Projekt. Der Projektdialog bearbeitet weiterhin nur
das fuehrende: eine Liste darin wuerde den Dialog sprengen, in dem es
eigentlich um das Projekt geht.

Eine frei waehlbare Bezeichnung tritt an die Stelle von owner/name. Die
Karte zeigt "+2 Repos" und im Titel, welche es sind.

repoTitle steht in lib/git/meta statt bei den uebrigen Repository-Helfern:
lib/repositories zieht Prisma und damit node:-Module nach, die in einer
Client-Komponente nicht landen duerfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:08 +02:00
florian.wildeandClaude Opus 5 a843f08211 feat(git): mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber
eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene
Tabelle, der Snapshot haengt jetzt am Repository.

Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden
kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem*
erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt.

Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt
verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand
bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten
Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem
seiner Teile gearbeitet wird.

Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst
zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie
sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander -
ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das,
worauf Rate-Limits reagieren.

Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und
Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft
gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe
Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere
Aufgaben ohne Herkunft bleiben erlaubt.

Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das
fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo,
und der Import muss von der neuen Tabelle nichts wissen.

Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt
lesbar: dort stand genau ein Repository flach am Projekt.

Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den
Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht.
Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung,
Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo
bekommen keine leere Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:23:53 +02:00
florian.wildeandClaude Opus 5 2bff6faf3d feat(auth): Zwei-Faktor-Anmeldung per TOTP
Ein Einmalkennwort zusaetzlich zum Passwort, mit QR-Code zum Einrichten und
zehn Wiederherstellungscodes fuer den Fall, dass das Geraet verloren geht.

Die Rechnung nach RFC 6238 steht in lib/auth/totp.ts statt in einer
Abhaengigkeit. Es sind wenige Zeilen, das Verfahren liegt seit 2011 fest, und
der RFC bringt Testvektoren mit - die Richtigkeit ist damit belegt und nicht
nur behauptet. Fuer den QR-Code kommt qrcode dazu; ihn selbst zu zeichnen
waere unverhaeltnismaessig.

Einrichtung in zwei Schritten: der Schluessel wird hinterlegt, aber erst ein
bestaetigter Code schaltet ihn scharf. Wer den QR-Code nicht scannen konnte
und abbricht, sperrt sich sonst beim naechsten Anmelden selbst aus.

Zwischen Passwort und Code steht ein eigenes Modell statt einer halbfertigen
Sitzung. Eine Sitzungszeile mit einem "noch nicht gueltig"-Merker waere eine
Zeile, die bei einem vergessenen Filter irgendwo doch als Anmeldung
durchgeht; dieses Versehen kann hier nicht passieren. Nach fuenf
Fehlversuchen ist der Vorgang verbrannt, damit sechs Stellen nicht in Ruhe
durchprobiert werden.

Die Wiederherstellungscodes liegen als SHA-256 statt als scrypt: sie sind
zufaellig erzeugt und lang genug, dass Raten aussichtslos ist - ein
langsames Verfahren brauchte bei jeder Anmeldung zehn teure Vergleiche und
kaufte nichts dafuer. Das Alphabet laesst O, I und B weg, weil die Codes von
Hand abgeschrieben werden.

Abschalten und Neuerzeugen verlangen das Passwort. Ein unbeaufsichtigter
Rechner mit offener Sitzung soll den zweiten Faktor nicht im Vorbeigehen
entfernen koennen.

Nachgewiesen im Browser: Einrichtung, Anmeldung mit Code, Anmeldung mit
Wiederherstellungscode, derselbe Code ein zweites Mal abgelehnt, Abschalten
mit falschem und richtigem Passwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:37 +02:00
florian.wildeandClaude Opus 5 af562584ad feat(projects): Aktivitaetsverlauf je Projekt
Was ist hier eigentlich passiert - eine Zeitleiste auf der Projektseite
beantwortet das jetzt: Statuswechsel, Notizen, Aufgaben, neue Commits.

Der Verlauf soll erzaehlen, nicht protokollieren. Deshalb bekommen Status
und Repo-Verknuepfung einen eigenen Eintrag, waehrend alle uebrigen Felder
zu einem zusammengefasst werden - sonst ertraenkt eine Feinjustage der Farbe
alles Wichtige. Ein Titel, der sich aendert, kommt gar nicht vor.

Beim Abgleich ist Zurueckhaltung noch wichtiger: er laeuft im Zweifel
stuendlich. Vermerkt wird nur, wenn der letzte Commit ein anderer ist als
beim letzten Mal, und beim ersten Abgleich gar nicht - dass ein Repo Commits
hat, ist keine Neuigkeit. Fehlschlaege stehen nur beim Uebergang in den
Fehlerzustand drin, sonst schlaegt ein abgelaufenes Token stuendlich neu auf.

Der Text wird beim Schreiben formuliert, nicht beim Anzeigen. Ein Eintrag
soll auch dann lesbar bleiben, wenn es die Notiz, um die es ging, laengst
nicht mehr gibt.

Zweihundert Eintraege je Projekt bleiben erhalten, der taegliche Durchgang
stutzt den Rest. Der Export nimmt den Verlauf nicht mit - er beschriebe, was
in dieser Instanz passiert ist, und verwiese auf Konten, die es anderswo
nicht gibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:19 +02:00
florian.wildeandClaude Opus 5 fa5fd78318 test: Schnellsuche und Issue-Uebernahme abdecken, Version 0.9.0
CI / pruefen (push) Has been cancelled
Zwei neue Ende-zu-Ende-Tests: die Palette findet Projekt und Notiz und
oeffnet den Treffer mit Enter; die Uebernahme meldet ohne Repo-Stand einen
Hinweis statt still zu scheitern. Damit sind es 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:36:44 +02:00
florian.wildeandClaude Opus 5 75460c8cf4 feat(tasks): offene Git-Issues als Aufgaben uebernehmen
Ein Klick im Git-Panel macht aus den offenen Issues Aufgaben - mit Titel,
Labels und einem Link zurueck ins Git-System.

Grundlage ist der zuletzt geholte Snapshot. Hier wird bewusst nichts
nachgeladen: ein Klick soll nicht ungefragt fremde APIs ansprechen und
womoeglich in ein Rate-Limit laufen. Wer den neuesten Stand will, gleicht
vorher ab; ohne Snapshot sagt der Server das statt still nichts zu tun.

Die Issue-Nummer bleibt an der Aufgabe und ist je Projekt eindeutig. Ein
zweiter Klick legt deshalb keine Dubletten an, auch wenn die Aufgabe
inzwischen verschoben oder umbenannt wurde. NULL gilt in Postgres als
verschieden, von Hand angelegte Aufgaben stoeren die Eindeutigkeit also
nicht.

Nachgewiesen an vercel/swr: 15 offene Issues uebernommen, beim zweiten
Aufruf 0 angelegt und 15 uebersprungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:36:44 +02:00
florian.wildeandClaude Opus 5 3bad4ace10 feat(ui): Schnellsuche mit Strg+K
Palette ueber die ganze Anwendung: Projekte nach Name, Kurzbeschreibung und
Tags, Notizen ueber die serverseitige Volltextsuche, dazu die Bereiche.
Ohne Eingabe stehen die zuletzt bearbeiteten Projekte bereit - danach sucht
man ueblicherweise.

Bedienung ueber Tastatur, wie man es erwartet: Pfeile waehlen, Enter oeffnet,
Escape schliesst, der ausgewaehlte Eintrag bleibt im Blick.

Die Projektliste wird beim ersten Oeffnen geholt und dann behalten. Sie bei
jedem Tastendruck neu zu laden waere Verschwendung, und veraltet ist sie
hoechstens um die Dauer einer Sitzung. Notizen sucht dagegen der Server,
entprellt ab zwei Zeichen - sie mitzuladen wuerde die Seite aufblaehen.

Wie Menue und Dialog haengt die Palette per Portal am body, aus denselben
Gruenden: overflow-hidden und backdrop-filter der Karten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:36:44 +02:00
florian.wildeandClaude Opus 5 a237a79915 docs: Stillstandsmeldung dokumentieren, Version 0.8.0
CI / pruefen (push) Has been cancelled
FEATURES erklaert, warum der Massstab der letzte Commit ist und nicht das
Aenderungsdatum, wen die Pruefung auslaesst und wann erneut gemeldet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 18ed4de9f9 feat(admin): Schwelle fuer den Stillstand einstellbar
Eigener Abschnitt mit der Schwelle von 14 Tagen bis zu einem Jahr, dem
Zeitpunkt der letzten Pruefung und der Zahl der Empfaenger. Steht die auf
null, sagt der Abschnitt das und verweist auf "Mein Konto".

Eine Aenderung der Schwelle setzt lastStaleCheckAt zurueck, damit sie beim
naechsten Tick greift und nicht erst in 24 Stunden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 4d1389c3a8 feat(notify): stillstehende Projekte melden
Vierter Anlass: seit laengerem kein Commit. Er haengt an keinem Ereignis -
es passiert ja gerade nichts - und braucht deshalb einen eigenen Durchlauf
im Zeitgeber, hoechstens einmal am Tag und unabhaengig vom Abgleich. Hoert
niemand auf den Anlass, laeuft die Pruefung gar nicht erst.

Massstab ist ausschliesslich der letzte Commit im verknuepften Repository.
Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und
wuerde den Stillstand immer wieder zuruecksetzen. Projekte ohne Repository
bleiben deshalb aussen vor, ebenso fertige und archivierte.

Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit
kam und es seitdem wieder still wurde - ein Projekt, das aufwacht und wieder
einschlaeft, meldet sich also ein zweites Mal.

Beim Testen kam heraus, dass ich den neuen Anlass zwar ins Prisma-Schema und
in die Oberflaeche eingetragen hatte, nicht aber ins Zod-Schema - die API
lehnte ihn mit 422 ab. Behoben, und ein Test haelt beide Listen kuenftig
zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 252c0686df docs: Benachrichtigungen dokumentieren, Version 0.7.0
CI / pruefen (push) Has been cancelled
FEATURES beschreibt die drei Anlaesse, die Entprellung samt ihrer beiden
Feinheiten, die drei Zustellwege und die Absicherung. README bekommt die
neuen Endpunkte und Umgebungsvariablen, .env.example die SMTP-Vorlage.

ROADMAP: Benachrichtigungen erledigt. Neu an erster Stelle steht das Melden
stillstehender Projekte - das ist der einzige Anlass, der kein Ereignis hat,
an dem er haengt, und braucht deshalb einen eigenen Durchlauf. Ein doppelter
Eintrag zu Webhooks statt Polling ist dabei aufgefallen und entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:23:06 +02:00
florian.wildeandClaude Opus 5 f1e4271488 feat(ui): Benachrichtigungsziele unter "Mein Konto"
Beliebig viele Ziele je Konto, jedes mit eigenen Anlaessen. Jedes laesst sich
einzeln testen und pausieren; Ergebnis und Zeitpunkt der letzten Zustellung
stehen an der Karte.

Der Weg laesst sich nach dem Anlegen nicht mehr aendern - ein Webhook-Ziel zu
einer E-Mail-Adresse umzubiegen ergaebe keinen Sinn und muesste das
Geheimnis mitwandern lassen. Ist kein SMTP hinterlegt, ist die Auswahl
"E-Mail" gesperrt und erklaert, warum.

targetSafeSelect liegt in lib/notify/types statt in der Route: Next laesst in
route.ts nur bestimmte Exporte zu und bricht den Bau bei allem anderen ab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 3bfed6d9f6 feat(notify): Benachrichtigungen ueber ntfy, Webhook und E-Mail
Drei Anlaesse: fehlgeschlagener Abgleich, rote CI, neues Release. Die Daten
dafuer liegen seit dem CI-Status vor, es fehlte nur der Weg nach draussen.

Der Kern ist die Entprellung. Gemeldet wird ausschliesslich bei einer
Aenderung gegenueber dem zuletzt gemeldeten Stand, der am Projekt vermerkt
ist. Ein Repo mit abgelaufenem Token meldet sich damit einmal statt bei
jedem stuendlichen Durchlauf. Zwei Feinheiten:

- Beim allerersten Abgleich wird kein Release gemeldet, sonst waere jede
  bereits vorhandene Version eine "Neuigkeit".
- Der Merkstand wird auch ohne eingerichtetes Ziel fortgeschrieben, sonst
  kaeme beim ersten Ziel die ganze Vergangenheit hinterher.

Zustellwege:
- ntfy mit Titel, Prioritaet und Symbol als Kopfzeilen, optionalem
  Bearer-Token. Emojis werden aus den Kopfzeilen entfernt statt durch
  Fragezeichen ersetzt - Kopfzeilen duerfen nur Latin-1 tragen, und das
  Sinnbild liefert ohnehin der Tags-Header.
- Webhook mit JSON, Projektbezug und Tieflink. Mit Geheimnis wird der Rumpf
  per HMAC-SHA256 signiert, damit der Empfaenger die Herkunft pruefen kann.
- E-Mail ueber SMTP, verfuegbar sobald SMTP_HOST gesetzt ist.

Zieladressen sind Benutzereingaben, die der Server abruft - sie laufen
deshalb durch denselben SSRF-Schutz wie die Git-Verbindungen. Redirects
werden nicht verfolgt, weil ein Umleitungsziel nicht geprueft waere.
Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung darf
den Abgleich nicht scheitern lassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 d1b9dfe535 docs: Testebenen dokumentieren, Version 0.6.0
CI / pruefen (push) Has been cancelled
FEATURES beschreibt beide Ebenen - Unit-Tests ohne Datenbank und
Ende-zu-Ende-Tests gegen die gebaute Anwendung - samt der beiden
Vorkehrungen gegen einen Lauf auf der echten Datenbank. Dazu die
Voraussetzungen fuer Gitea Actions.

ROADMAP: der Testausbau war Punkt 1 und ist erledigt; Benachrichtigungen
ruecken auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
florian.wildeandClaude Opus 5 ebea3786b1 ci: Pipeline fuer Gitea Actions
Prueft jeden Push und jeden Pull Request: Typen, Unit-Tests, Bau und
Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlaegt etwas
fehl, wird der Playwright-Bericht als Artefakt abgelegt.

Der Datenbankname des Dienstes enthaelt "test", sonst verweigert
global-setup.ts den Dienst. In CI wird das mitgelieferte Chromium benutzt
statt des oertlich installierten Chrome.

Die Syntax ist GitHub-kompatibel - fuer GitHub genuegt es, die Datei nach
.github/workflows zu kopieren.

Nebenbei bekommt der Knopf des Benutzermenues eine Beschriftung. Ohne sie
las ein Screenreader auf schmalen Anzeigen nur das zweibuchstabige Kuerzel
vor, und im Test war er nicht eindeutig vom Datenmenue zu unterscheiden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
florian.wildeandClaude Opus 5 6e63fec49b test: Ende-zu-Ende-Tests mit Playwright
13 Tests gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 -
also durch Middleware, Authentifizierung, Validierung und Datenbank hindurch.
Genau die Schicht, die Unit-Tests nicht erreichen. Laufzeit rund 20 Sekunden.

api.spec.ts prueft, was bisher nur von Hand geprueft war:
- Einrichtung sperrt sich nach dem ersten Konto
- ohne Anmeldung liefert jeder Endpunkt 401
- fremdes Origin und falscher Content-Type werden abgewiesen
- SSRF-Sperren greifen
- Datentrennung: ein zweites Konto bekommt auf fremde Projekte, Aufgaben und
  Adminwege durchgaengig 404 beziehungsweise 403, das Original bleibt
  unveraendert
- Aufgaben: Reihenfolge, abgeleiteter Fortschritt, untergeschobene IDs
- Export ohne Geheimnisse, Import je Strategie

ui.spec.ts geht den Weg eines Menschen: anmelden, Projekt anlegen, Aufgaben
anlegen und ziehen, suchen, abmelden. Das Ziehen wird nach einem Neuladen
erneut geprueft - sonst waere nur die Anzeige getestet, nicht das Speichern.

Zwei Vorkehrungen:
- global-setup.ts bricht ab, wenn der Datenbankname nicht "test" enthaelt,
  und zwar vor dem TRUNCATE. Der teuerste denkbare Fehler waere eine
  Testreihe, die die Produktivdaten leert.
- Playwright startet den Server selbst mit eigener DATABASE_URL und eigenem
  Port; eine laufende Instanz auf 3000 stoert nicht.

Beim Schreiben zeigte sich, dass synthetische Zieh-Gesten eine Pause vor dem
Loslassen brauchen - dnd-kit wertet die Bewegung in einem eigenen Frame aus
und faende sonst kein Ziel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:36 +02:00
florian.wildeandClaude Opus 5 3ce866da2c docs: CLAUDE.md mit Projektkontext
Damit eine neue Sitzung in diesem Ordner nicht bei null anfaengt: die
Befehle, die nicht verhandelbaren Regeln (Datentrennung, Pruefung in jedem
Endpunkt, keine Geheimnisse nach aussen) und vor allem die Fallstricke, die
in diesem Projekt schon zugeschlagen haben - node-Module in
Client-Komponenten, instrumentation.ts im Edge-Bundle, CSS-Klassen ausserhalb
von @layer, Overlays in Karten mit backdrop-filter.

Dazu der Hinweis, dass Migrationen hier ueber "migrate diff" entstehen, weil
"migrate dev" interaktiv ist, und die Erinnerung, bei Oberflaechenaenderungen
tatsaechlich hinzusehen statt es zu behaupten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:36 +02:00
florian.wildeandClaude Opus 5 5b40d5e4ff feat(api): progressFromTasks im Projekt-Endpunkt
Der Schalter laesst sich ueber PATCH /api/projects/:id setzen. Beim
Einschalten wird der abgeleitete Wert sofort berechnet, damit er nicht bis
zur naechsten Aufgabenaenderung falsch steht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:30:04 +02:00
florian.wildeandClaude Opus 5 c50fa811c5 docs: Aufgabenbrett dokumentieren
FEATURES bekommt ein eigenes Kapitel: die vier Spalten, das Ziehen mit
Sortierung, Faelligkeiten, der abgeleitete Fortschritt und die Erklaerung,
warum die Reihenfolge als vollstaendige Liste je Spalte gespeichert wird.

ROADMAP: "Aufgaben und Checklisten" wandert zu den erledigten Punkten. Neu
unter den Kleinigkeiten stehen wiederkehrende Aufgaben und das Uebernehmen
offener Git-Issues als Aufgaben - beides ergibt sich erst jetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:51 +02:00
florian.wildeandClaude Opus 5 e40a3bd62e fix: Warnung beim Einlesen der Versionsnummer beseitigen
Der benannte Import aus der package.json loeste in Webpack die Warnung
"Should not import the named export 'version'" aus. Jetzt als Standardimport.
Version auf 0.5.0 gehoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 79c4bd1967 feat(export): Aufgaben in Export, Import und Sicherung aufnehmen
Aufgaben gehen mit - JSON-Export, Import und Instanz-Sicherung. Beim Import
wird die Position aus der Datei uebernommen, notfalls die Reihenfolge in ihr.
Wie bei den Notizen werden Aufgaben beim Ersetzen ausgetauscht statt
zusammengefuehrt, damit ein erneuter Import keine Dubletten erzeugt.

Der Markdown-Export bekommt einen Abschnitt "Aufgaben", nach Spalten
gegliedert und als Checkliste geschrieben - so bleibt der Stand auch
ausserhalb von Nebula lesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 0212dad2bf feat(ui): Aufgabenbrett auf der Projektseite
Vier Spalten, Karten per Ziehen verschiebbar - zwischen den Spalten und
innerhalb einer Spalte. Waehrend des Ziehens fuehrt der oertliche Zustand,
danach wird die neue Ordnung als Ganzes gespeichert; scheitert das, springt
das Brett auf den Serverstand zurueck.

Details:
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den
  Dialog oeffnet.
- Der Spaltenwechsel passiert schon beim Darueberziehen, nicht erst beim
  Ablegen - sonst wirkt das Brett traege.
- Der Serverstand wird nur uebernommen, wenn gerade nicht gezogen wird,
  sonst spraenge die Karte mitten in der Bewegung zurueck.
- Faelligkeiten faerben sich: ueberfaellig rot, heute gelb, demnaechst
  gedaempft.

Ist "Fortschritt aus Aufgaben" eingeschaltet, wird der Regler gesperrt statt
einen Wert anzubieten, der beim naechsten Aufgabenwechsel ueberschrieben
wuerde - im Dialog wie im Kopfbereich. Die Projektkarte zeigt das Verhaeltnis
als "3/8".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 72bcf824ce feat(tasks): Aufgaben je Projekt mit Spalten und Reihenfolge
Neues Modell Task: Titel, optionale Beschreibung, Spalte, Position,
Faelligkeit und Labels. Vier Spalten - offen, in Arbeit, blockiert,
erledigt.

Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte hier
bedeutsam und wird deshalb gespeichert. Der Reorder-Endpunkt nimmt je Spalte
die vollstaendige Liste in ihrer neuen Reihenfolge entgegen und leitet Status
und Position daraus ab. Das ist wiederholbar, kommt ohne Positionsrechnerei
im Browser aus, und ein verlorener Aufruf laesst sich einfach erneut senden.

Sicherheitsrelevant: der Endpunkt bekommt IDs aus dem Browser. Er
aktualisiert ausschliesslich Aufgaben, die wirklich zu diesem Projekt
gehoeren, und meldet uebergangene IDs zurueck - sonst liessen sich fremde
Aufgaben ueber untergeschobene IDs verschieben.

doneAt wird nur beim tatsaechlichen Wechsel nach "erledigt" gesetzt und beim
Zurueckziehen geleert. Ein Umsortieren innerhalb der Spalte laesst das Datum
in Ruhe.

Der Fortschritt eines Projekts laesst sich wahlweise aus dem Anteil
erledigter Aufgaben ableiten. Er wird nach jeder Aenderung neu berechnet und
auch sofort beim Einschalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 298da72b6d docs: v0.3.0 und v0.4.0 dokumentieren, Roadmap fortschreiben
FEATURES beschreibt Markdown, Volltextsuche, PR- und Issue-Listen, den
CI-Status samt Herkunft je Git-System, die Aktivitaetskurve inklusive ihrer
Grenze, das Kanban-Board und das Testfundament.

ROADMAP: sieben Punkte wandern zu den erledigten. Ganz oben steht jetzt der
Ausbau der Tests auf Datenbank und Browser - das Fundament steht, die
Schicht darueber fehlt und ich pruefe weiterhin von Hand. Danach
Benachrichtigungen, die von CI-Status und Abgleich nun profitieren wuerden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 92285b11fc fix: Versionsnummer aus einer einzigen Quelle
Die Fusszeile zeigte v0.2.0, die package.json stand auf 0.1.0 - beim
Auth-Feature hatte ich nur die Anzeige hochgezaehlt. Jetzt kommt die Version
aus der package.json, kann also nicht mehr auseinanderlaufen. Stand: 0.4.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 e55dab6473 feat(ui): Kanban-Board mit Ziehen und Ablegen
Dritte Ansicht neben Raster und Gruppierung: eine Spalte je Status, Karten
per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und erst danach
gespeichert; scheitert das Speichern, springt die Karte zurueck.

Details, die den Unterschied machen:
- Gezogen wird am Griff links, nicht an der ganzen Karte - so bleibt der
  Titel ein normaler Link.
- Das Ziehen startet erst nach sechs Pixeln Bewegung, sonst liesse sich die
  Karte nicht mehr anklicken. Auf Touchgeraeten nach kurzem Halten.
- Die mitgezogene Karte haengt in einem DragOverlay am Cursor, das Original
  bleibt blass stehen - sonst wuerde die Spalte unter dem Finger springen.
- Bestaetigt der Server die Verschiebung, wird die oertliche Ausnahme wieder
  entfernt, damit sie nicht veraltet.

Die Reihenfolge innerhalb einer Spalte folgt der gewaehlten Sortierung.
Frei sortieren hiesse, eine Position je Projekt zu speichern - bewusst
nicht gebaut.

Nachgewiesen im Browser: Karte aus "Idee" nach "Fertig" gezogen, Zielspalte
hebt sich hervor und zeigt "Hier ablegen", danach steht das Projekt
serverseitig auf DONE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 de8f13f242 test: Fundament mit Vitest fuer die reine Logik
51 Tests, unter einer Sekunde, ohne DOM, Datenbank oder laufenden Server.
Abgedeckt sind die Stellen, an denen ein Fehler teuer waere:

- Slug-Erzeugung mit Umlauten, Tag-Aufbereitung, Textkuerzung
- URL-Zerlegung fuer https, SSH, verschachtelte GitLab-Gruppen und
  Oberflaechenpfade; Provider-Erkennung; Bildung der Aktivitaetskurve
- SSRF-Sperrlisten: Loopback, Link-Local samt Metadaten-Endpunkt, CGNAT,
  Multicast, IPv4-gemappte IPv6-Adressen; Formpruefung; alle drei
  Umgebungsschalter
- Passwoerter: scrypt-Rundlauf, unterschiedliches Salz je Hash, Ablageformat,
  Verhalten bei kaputten Hashes, Unicode-Normalisierung, Passwortregeln

Bewusst ohne Datenbank- und Browsertests - die brauchen Infrastruktur und
gehoeren in eine Pipeline. Steht als naechster Punkt in der ROADMAP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:19 +02:00
florian.wildeandClaude Opus 5 03ce74faa2 feat(git): offene PRs und Issues, CI-Status und Commit-Aktivitaet
Bisher standen nur Zahlen da. Jetzt kommen die Inhalte mit.

Pull Requests und Issues: bis zu 50 offene je Liste, mit Nummer, Titel,
Autor, Alter, Labels und Link. Bei GitHub und Gitea liefert der
Issue-Endpunkt auch Pull Requests mit - die werden herausgefiltert.

CI-Status des letzten Laufs auf dem Default-Branch:
- GitHub ueber actions/runs; dort sind Lauf-Status und Ergebnis getrennt und
  werden auf einen Wert zusammengefuehrt
- GitLab ueber pipelines
- Gitea ueber den kombinierten Commit-Status. Das deckt Gitea Actions und
  externe Systeme wie Woodpecker gleichermassen ab - ein reiner
  Actions-Endpunkt existiert auf aelteren Instanzen nicht.

Commit-Aktivitaet als Balkenreihe. Berechnet aus den ohnehin geholten
Commits (jetzt 100 statt 15, angezeigt bleiben 15) statt ueber eigene
Statistik-Endpunkte: die gibt es nicht bei allen drei Systemen, und bei
GitHub antworten sie beim ersten Aufruf mit 202 und ohne Daten.

Beim Testen gegen gitlab-runner fiel auf, dass alle 100 Commits in die
letzten 30 Tage fielen - die frueheren Tage haetten faelschlich Null
gezeigt, obwohl wir ueber sie nichts wissen. Die Kurve wird deshalb auf den
tatsaechlich belegten Zeitraum gekuerzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:19 +02:00
florian.wildeandClaude Opus 5 4bb600bb06 feat(search): Volltextsuche ueber Notizen
Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
Name, Kurzbeschreibung, Tags und Repo-Pfad ab. Notizen liegen dort nicht vor
- sie mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb ein eigener
Endpunkt, der Postgres mit seiner deutschen Textsuche arbeiten laesst.

- websearch_to_tsquery versteht Eingaben wie "docker -compose" und wirft bei
  Unsinn keinen Fehler
- ts_headline liefert den Ausschnitt gleich mit; die Hervorhebung kommt als
  [[…]] zurueck, damit die Oberflaeche kein HTML aus der Datenbank einbetten
  muss
- deutsche Stammformen greifen: "Webhook" findet auch "Webhooks"
- entprellt ab zwei Zeichen, laufende Abfragen werden abgebrochen

Dazu ein GIN-Index auf dem Suchausdruck. Prisma kann Ausdrucksindizes im
Schema nicht abbilden - nachgeprueft: "migrate diff" laesst ihn in Ruhe und
schlaegt kein DROP vor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:03:55 +02:00
florian.wildeandClaude Opus 5 04e8437f29 feat(ui): Markdown in Beschreibung und Notizen
Bisher wurde alles als reiner Text ausgegeben. Jetzt werden Ueberschriften,
Listen, Tabellen, Codebloecke, Zitate und Links dargestellt, und aus
"- [ ] Aufgabe" werden echte Checklisten.

Sicherheitsrelevant: der Inhalt stammt vom Benutzer. Ohne rehype-sanitize
waere das ein XSS-Loch, sobald jemand rohes HTML in eine Notiz schreibt. Das
Standardschema erlaubt nur harmlose Elemente; ergaenzt wird nur, was fuer
Aufgabenlisten und Sprachklassen an Codebloecken noetig ist. Links bekommen
zusaetzlich target=_blank mit noopener und nofollow.

Die Formatierung steht in components.css: Tailwinds Preflight setzt alle
Abstaende und Schriftgroessen zurueck, Ueberschriften und Listen brauchen
also wieder Form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:03:55 +02:00
florian.wildeandClaude Opus 5 67d9222c97 docs: Export, Import und Sicherung dokumentieren
README bekommt einen eigenen Abschnitt mit den vier Wegen, den drei
Importstrategien und beiden Sicherungsarten - samt der Empfehlung, eine
Sicherung einmal in eine Wegwerf-Datenbank zurueckzuspielen. FEATURES
beschreibt dasselbe ausfuehrlicher.

In der ROADMAP wandert Export und Import zu den erledigten Punkten, mit den
getroffenen Entscheidungen: keine Tokens, keine Snapshots, Voll-Sicherung
nur fuer Administratoren. Die folgenden Punkte ruecken auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:57 +02:00
florian.wildeandClaude Opus 5 7a98c8852e feat(ops): Sicherungsskripte mit pg_dump fuer Windows und Unix
Die JSON-Sicherung im Adminbereich eignet sich zum Umziehen. Zum
Wiederherstellen einer kaputten Instanz braucht es dagegen einen echten
Dump - Schema, Daten, Sequenzen, Indizes.

backup.ps1 und backup.sh fuehren pg_dump im Container aus, es muss also
nichts auf dem Rechner installiert sein. Benutzer und Datenbankname kommen
aus der .env, damit sie nicht doppelt gepflegt werden. Der Dump entsteht mit
--clean --if-exists und ist damit fuer sich allein einspielbar.

restore.* fragt vor dem Ueberschreiben nach und laesst sich ueber -Database
beziehungsweise NEBULA_DB_NAME auf eine andere Datenbank umlenken. Damit
laesst sich eine Sicherung pruefen, ohne die echte anzufassen - eine
Sicherung, die nie zurueckgespielt wurde, ist keine.

/backups liegt im .gitignore: die Dateien enthalten alle Daten der Instanz.

Geprueft am echten Bestand: Dump 30,9 KB mit allen neun Tabellen,
Rueckspielung in eine Pruefdatenbank ergab dieselben Zahlen, das Original
blieb unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:57 +02:00
florian.wildeandClaude Opus 5 a17f32c378 feat(ui): Export und Import in der Oberflaeche
Dashboard: ein Datenmenue neben "Neues Projekt" mit Export aller Projekte
und Import. Projektseite: Ausklappmenue mit JSON und Markdown.
Adminbereich: Abschnitt "Sicherung" mit deutlichem Hinweis, was in der Datei
steht und wie vertraulich sie zu behandeln ist.

Der Import-Dialog fuehrt in zwei Schritten: Datei waehlen, Vorschau ansehen,
erst dann schreiben. Ein Wechsel der Strategie berechnet die Vorschau neu.
Der Ausfuehren-Knopf bleibt gesperrt, solange sich nichts aendern wuerde -
sonst waere er ein Angebot, das ins Leere laeuft. Fehlende Git-Verbindungen
werden gesondert hervorgehoben, mit dem Hinweis, dass Tokens nie in
Exportdateien stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:57 +02:00
florian.wildeandClaude Opus 5 91c94e7383 feat(export): Projekte exportieren und importieren
Bislang lagen alle Daten ausschliesslich in der Datenbank - ein
"docker compose down -v" haette alles geloescht, ohne Rueckweg.

Export:
- alle eigenen Projekte oder ein einzelnes als JSON
- ein Projekt zusaetzlich als lesbares Markdown, inklusive letztem
  bekannten Git-Stand

Was bewusst nicht in der Datei steht:
- Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
  Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
  Exportdatei landet sonst schnell in einem Download-Ordner oder einem
  Cloud-Abgleich.
- Repo-Snapshots. Reproduzierbar und gross; ein Abgleich holt sie zurueck.
- Datenbank-IDs und der Besitzer. Beides gilt nur in der Quellinstanz.

Import:
- die hochgeladene Datei ist Fremdinhalt und wird vor jeder Verwendung
  streng gegen ein Zod-Schema geprueft
- Abgleich ueber den Slug, drei Strategien: ueberspringen, zusaetzlich
  anlegen, ersetzen
- dryRun liefert dieselbe Auswertung, ohne zu schreiben - Grundlage der
  Vorschau in der Oberflaeche
- Verbindungen werden ueber Name oder Provider samt Basis-URL zugeordnet;
  fehlt eine, wird das Projekt ohne Verknuepfung angelegt und das gemeldet
- beim Ersetzen werden Notizen ausgetauscht statt zusammengefuehrt: das ist
  vorhersagbar, und ein erneuter Import erzeugt keine Dubletten

Die Instanz-Sicherung fuer Administratoren enthaelt dagegen alles, was zum
Wiederherstellen noetig ist - Passwort-Hashes und Token-Chiffrate
eingeschlossen. Ohne dasselbe APP_SECRET sind die Tokens wertlos. Sitzungen
und Passkeys bleiben draussen: erstere sind kurzlebig, letztere haengen an
Geraet und rpID.

readJsonBody bekommt eine einstellbare Obergrenze - 256 KB reichen fuer eine
Exportdatei mit vielen Notizen nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:26 +02:00
florian.wildeandClaude Opus 5 bad90753d0 docs: Abgleich dokumentieren und Roadmap anlegen
README und FEATURES beschreiben den automatischen Abgleich samt
Zurueckhalten nach Fehlschlaegen und dem Weg ueber einen externen Zeitgeber.

Neu ist ROADMAP.md: die bisherige Liste "Ideen fuer spaeter" war eine lose
Aufzaehlung ohne Reihenfolge und ohne Aufwandsabschaetzung. Jetzt nach
Prioritaet gegliedert, jeder Punkt mit Aufwand und - wo es welche gibt -
offenen Fragen. Ganz oben steht Export und Import in vier Stufen, inklusive
der Frage, ob Git-Tokens mitgehen sollen (Vorschlag: nein).

Am Ende steht bewusst auch, was nicht kommt und warum - das erspart
spaetere Diskussionen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00
florian.wildeandClaude Opus 5 4f2c221db6 feat(admin): Abgleich einstellbar und Schalter je Projekt
Adminbereich, Abschnitt "Automatischer Abgleich":
- ein- und ausschalten
- Abstand von 15 Minuten bis taeglich
- Projekte je Durchlauf
- fertige und archivierte auslassen
- Zustand auf einen Blick: verknuepfte Projekte, letzter und naechster
  Durchlauf, Kurzbericht des letzten Laufs
- Knopf "Jetzt abgleichen" fuer einen sofortigen Durchlauf

Der Endpunkt POST /api/admin/sync/run dient zugleich als Einstieg fuer einen
externen Zeitgeber, falls jemand lieber von aussen taktet als den eingebauten
Automatismus zu nutzen.

Im Projektdialog laesst sich ein einzelnes Projekt vom Abgleich ausnehmen -
nuetzlich bei einem Repo, dessen Token staendig Fehler wirft. Manuell
synchronisieren geht unabhaengig davon immer.

Die Zahl der in Frage kommenden Projekte wird ueber alle Konten gezaehlt: der
Abgleich ist ein Serverauftrag, keine Dateneinsicht. Ein Administrator sieht
dadurch weiterhin keine fremden Projekte.

Nachgewiesen: Durchlauf 1 aktualisiert das echte Repo und meldet das kaputte
als Fehlschlag, Durchlauf 2 stellt das kaputte zurueck, das ausgenommene
Projekt bleibt in beiden unberuehrt. Der Zeitplan loeste eigenstaendig aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00
florian.wildeandClaude Opus 5 ed2637877e feat(sync): automatischer Abgleich nach Zeitplan
Bisher musste jedes Projekt einzeln von Hand synchronisiert werden. Jetzt
gibt es einen Zeitgeber im Serverprozess, angestossen von
instrumentation.ts. Fuer eine Einzelinstanz ist das die einfachste
tragfaehige Loesung: kein zusaetzlicher Container, kein externer Cron, keine
zweite Konfiguration.

Ruecksicht auf fremde API-Kontingente war der bestimmende Gedanke:

- standardmaessig abgeschaltet - der Abgleich verbraucht fremdes Kontingent,
  das soll eine bewusste Entscheidung sein
- je Durchlauf nur eine begrenzte Zahl Projekte, die am laengsten nicht
  abgeglichenen zuerst; der Rest kommt beim naechsten Mal
- nach jedem Fehlschlag verdoppelt sich der Abstand bis zum naechsten
  Versuch, gedeckelt beim Sechzehnfachen. Ein Repo mit abgelaufenem Token
  blockiert damit nicht dauerhaft das Kontingent.
- kurze Pause zwischen zwei Projekten
- zwei Durchlaeufe ueberschneiden sich nie
- fertige und archivierte Projekte lassen sich auslassen
- einzelne Projekte lassen sich ausnehmen

Der Zaehler fuer Fehlschlaege wandert in syncProject: bei Erfolg zurueck auf
null, bei Fehlschlag hoch. So stimmt er auch nach einem manuellen Abgleich.

sync.ts wird zu sync/index.ts, damit Modul und Ordner nicht nebeneinander
stehen. Alle Importpfade bleiben unveraendert.

Zur Form der Abfrage in instrumentation.ts: Next uebersetzt die Datei auch
fuer den Edge-Runtime. Der Import muss deshalb *in* der positiven Bedingung
stehen, sonst bleibt er im Edge-Bundle stehen und der Build scheitert an
node:crypto. Mit einem frueh abbrechenden return passiert genau das.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:37:54 +02:00
florian.wildeandClaude Opus 5 b865cf49bd fix(ui): Anzeigefehler aus der Browser-Pruefung beheben
Erstmals mit echtem Browser nachgesehen (headless Chrome ueber Playwright,
Screenshots aller Ansichten). Dabei fuenf Dinge gefunden:

1. Menue klebte am Fensterrand statt am Ausloeser. Bei einem Ausloeser weit
   links wurde die bevorzugte rechtsbuendige Ausrichtung negativ und dann auf
   den Rand geklemmt - das Menue stand quer ueber der Seite. Jetzt wird bei
   Ueberlauf auf die andere Ausrichtung gewechselt und erst danach geklemmt.

2. .popover war mit 97 % nicht ganz deckend, wodurch "Bearbeiten" und
   "Loeschen" schwach durchschienen. Jetzt vollstaendig deckend.

3. Der Schieberegler zeichnete die Spur rechts vom Griff in einem hellen
   Standardton - ein grelles Band im dunklen Layout. Ersetzt durch eine
   selbst gezeichnete Variante mit Farbverlauf und sichtbarem Griff. Auf der
   Projektseite ersetzt sie zugleich das bisherige unsichtbare Eingabefeld
   ueber einem gezeichneten Balken, das gar keinen Griff hatte.

4. Der Hinweis der Kachel "Ideen" wurde mitten im Wort abgeschnitten
   ("Noch ein Gedanke - nichts ge..."). Kurzer Text statt der langen
   Statusbeschreibung.

5. Nach der Einrichtung stand im Adminbereich "noch nie angemeldet", obwohl
   gerade eine Sitzung lief. Einrichtung und Registrierung setzen lastLoginAt
   jetzt mit.

Die Konsole blieb ueber alle Ansichten hinweg frei von Fehlern und Warnungen
- die strikte CSP mit Nonce und strict-dynamic bricht also nichts.

Geprueft an einer separaten Wegwerf-Datenbank auf Port 3100, damit die
laufende Instanz unberuehrt blieb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:46:00 +02:00
florian.wildeandClaude Opus 5 9a4cc75115 fix(ui): Menues und Dialoge ueber Portal ausgeben
Das Statusmenue auf der Projektseite wurde von den Schaltflaechen
"Bearbeiten" und "Loeschen" ueberdeckt, war unten abgeschnitten und der
Fortschrittswert schien hindurch. Drei Ursachen, alle mit derselben Wurzel:

1. Die Karten setzen overflow-hidden fuer den Akzentstreifen und den
   Farbverlaufsrand. Ein absolut positioniertes Kind wird daran
   abgeschnitten.
2. .glass setzt backdrop-filter, die Projektkarte bekommt beim Hovern
   zusaetzlich ein transform. Beides erzeugt einen Stacking-Context: das
   z-index des Menues galt nur noch innerhalb der Karte und verlor gegen
   spaeter gezeichnete Geschwister. Aus demselben Grund bezog sich das
   "position: fixed" der Klickflaeche zum Schliessen auf die Karte statt
   auf das Fenster - ein Klick daneben schloss das Menue nicht.
3. .glass hat 3,5 % Deckkraft. Als Kartenflaeche gut, als Overlay
   unbrauchbar. Der eigene backdrop-filter half nicht, weil er innerhalb
   eines Vorfahren mit backdrop-filter wirkungslos bleibt.

Behoben durch eine neue Popover-Komponente, die per createPortal am body
haengt, ihre Position aus dem Ankerelement berechnet und beim Scrollen und
bei Groessenaenderungen nachfuehrt. Reicht der Platz nach unten nicht, klappt
sie nach oben. Dazu die Klasse .popover mit nahezu deckendem Hintergrund.

Betrifft Statusauswahl, Benutzermenue und - aus demselben Grund - den
Modal-Dialog: der wird aus Karten heraus geoeffnet und haette sich sonst an
der Karte statt am Fenster ausgerichtet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:29:11 +02:00
florian.wildeandClaude Opus 5 ec7cb02a9a docs: Benutzerverwaltung, Passkeys und Sicherheitsmassnahmen dokumentieren
README: Abschnitt zur ersten Anmeldung mit den beiden Betriebsarten und der
Einrichtung eines Passkeys, erweiterte Variablentabelle mit dem Hinweis, dass
Name und URL zur Laufzeit gelesen werden, vollstaendige API-Uebersicht sowie
ein Sicherheitsabschnitt, der auch die Grenzen benennt: kein HSTS, keine
TLS-Terminierung, Ratenbegrenzung nur im Arbeitsspeicher, kein
Passwort-Zuruecksetzen per E-Mail, und warum Anfragen ohne Origin- und
Sec-Fetch-Site-Header durchgelassen werden.

FEATURES: neues Kapitel zu Konten und Zugang, aktualisiertes Datenmodell und
ausgebauter Sicherheitsabschnitt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
florian.wildeandClaude Opus 5 9a9b413d3e chore: neue Umgebungsvariablen in .env.example und Compose
APP_NAME, APP_TAGLINE und APP_URL sowie SESSION_TTL_DAYS und die drei
Schalter des SSRF-Schutzes werden an den App-Container durchgereicht.

Bei APP_URL steht ausdruecklich dabei, dass daraus die WebAuthn-rpID
abgeleitet wird, dass Passkeys einen sicheren Kontext brauchen und dass ein
spaeterer Hostnamenwechsel bestehende Passkeys ungueltig macht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
florian.wildeandClaude Opus 5 ed77ffaed2 feat(security): alle Daten auf den Besitzer einschraenken
Bisher sah jede Anfrage alles. Jetzt traegt jede Abfrage eine
Besitzerbedingung, und zwar an erster Stelle in der where-Klausel statt als
nachtraeglicher Vergleich - so kann sie nicht versehentlich uebersprungen
werden.

- Lesen: findFirst statt findUnique, immer mit ownerId
- Schreiben und Loeschen: updateMany/deleteMany mit ownerId in der
  where-Klausel. Fremde Datensaetze werden dadurch gar nicht erst getroffen,
  und die betroffene Zeilenzahl sagt, ob es den Datensatz gab.
- Notizen haengen ueber project.ownerId am Besitzer
- Beim Verknuepfen eines Projekts wird geprueft, dass die Git-Verbindung dem
  Aufrufer gehoert - sonst liesse sich eine fremde Verbindung und damit ein
  fremder Token unterschieben
- Fremde IDs liefern 404 statt 403: die Antwort bestaetigt nicht einmal,
  dass es den Datensatz gibt
- Ausgehende Endpunkte (Sync, Verbindungstest, Repo-Liste) zusaetzlich
  ratenbegrenzt, Basis-URLs gegen den SSRF-Schutz geprueft
- Slug und Verbindungsname werden je Besitzer auf Eindeutigkeit geprueft

Nachgewiesen: ein zweites Konto erhaelt auf GET, PATCH, DELETE, Notiz
anlegen und Sync eines fremden Projekts durchgaengig 404, auf die
Admin-Endpunkte 403, und das Zielprojekt bleibt unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
florian.wildeandClaude Opus 5 cf4103a46c feat(admin): Kontoverwaltung und Admin-Einstellungen
"Mein Konto": Profil, Passwort setzen oder wechseln, Passkeys anlegen,
umbenennen und entfernen, aktive Sitzungen einsehen und ueberall abmelden.
Der letzte Passkey laesst sich nicht entfernen, solange kein Passwort
gesetzt ist - sonst waere das Konto ausgesperrt.

Admin-Einstellungen: Betriebsart umschalten, Selbstregistrierung freigeben,
Konten anlegen, bearbeiten, deaktivieren und loeschen. Die Instanzwerte aus
der .env werden angezeigt, damit sichtbar ist, warum Passkeys gegebenenfalls
nicht verfuegbar sind.

Schutzregeln:
- Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein
  Konto existiert.
- Der letzte aktive Administrator kann weder herabgestuft noch deaktiviert
  oder geloescht werden.
- Das eigene Konto laesst sich nicht loeschen oder deaktivieren.
- Deaktivieren oder ein neues Passwort beenden sofort alle Sitzungen des
  Kontos.

Administratoren verwalten Konten, keine Inhalte: fremde Projekte sehen auch
sie nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:33 +02:00
florian.wildeandClaude Opus 5 4f85e00016 feat(auth): Einrichtung, Anmeldeseite und Registrierung
- /setup fuehrt beim ersten Aufruf durch das Anlegen des Administratorkontos
  und die Wahl der Betriebsart. Der Endpunkt funktioniert nur, solange es
  keinen Benutzer gibt; danach kann sich niemand mehr nachtraeglich ein
  Adminkonto erschleichen. Vorhandene Projekte ohne Besitzer werden dabei
  uebernommen und im Formular vorher angekuendigt.
- /login mit Passwort oder Passkey. Der Passkey-Knopf entfaellt, wenn APP_URL
  keinen sicheren Kontext ergibt - mit Hinweis, woran es liegt. Das Ziel nach
  der Anmeldung wird auf relative Pfade begrenzt, sonst waere der
  weiter-Parameter ein offener Redirect.
- /register nur im Mehrbenutzerbetrieb und nur, wenn ein Administrator die
  Selbstregistrierung freigegeben hat.
- Kopfzeile mit Benutzermenue: eigenes Konto, Admin-Einstellungen, Abmelden.
  Ohne Anmeldung bleibt sie auf das Logo reduziert.
- Layout liest Titel und Namen ueber generateMetadata, damit APP_NAME zur
  Laufzeit greift statt beim Build eingefroren zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:33 +02:00
florian.wildeandClaude Opus 5 13bc5aa0b8 feat(auth): Anmeldung mit Passwort und Passkey
Kern der Benutzerverwaltung.

Passwoerter:
- scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart
  und ohne zusaetzliches natives Modul
- Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben
  lassen
- fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe
  Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten
  existieren

Passkeys ueber SimpleWebAuthn 13:
- Anmeldung ohne Benutzernamen ueber auffindbare Credentials
- Challenges liegen in der Datenbank und werden beim Einloesen geloescht,
  koennen also nicht erneut eingespielt werden
- rpID und Origin kommen aus APP_URL

Sitzungen:
- Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash
- Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen
  wird - anders als bei einem selbsttragenden Token

Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin,
Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und
strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich
keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank.
Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig,
in jeder Seite und jedem Endpunkt.

config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber
NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben;
so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer
anderen Adresse betreiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:12 +02:00
florian.wildeandClaude Opus 5 01d42662ca feat(db): Datenmodell fuer Benutzer, Sitzungen und Passkeys
Neue Tabellen:
- User        Konto mit Rolle, optionalem Passwort-Hash, Sperrzaehler.
              Das Passwort ist optional, weil ein Konto auch ausschliesslich
              per Passkey nutzbar sein darf.
- Session     serverseitige Sitzung. Gespeichert wird nur der SHA-256-Hash
              des Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren
              Sitzungen ergibt.
- Credential  ein Passkey je Zeile, mit oeffentlichem Schluessel, Transporten
              und Signaturzaehler.
- WebAuthnChallenge  kurzlebig und einmalig verwendbar, deshalb in der
              Datenbank statt im Cookie.
- AppSettings  Betriebsart und Registrierung, genau eine Zeile.

Project und GitConnection bekommen einen Besitzer. Bewusst nullable: so
ueberstehen Bestandsdaten die Migration und werden beim Anlegen des ersten
Kontos uebernommen. Slug und Verbindungsname sind dadurch nur noch je
Besitzer eindeutig - zwei Personen duerfen beide ein Projekt "dashboard"
haben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:12 +02:00
florian.wildeandClaude Opus 5 dbcf30efc1 feat(security): SSRF-Schutz, Ratenbegrenzung und harte Body-Pruefung
Die Basis-URL einer Git-Verbindung ist eine Benutzereingabe, die der Server
anschliessend selbst abruft. Ohne Pruefung liesse sich darueber jede vom
Server erreichbare Adresse ansprechen - Cloud-Metadaten unter
169.254.169.254, lokal gebundene Dienste oder ein Portscan im Heimnetz.

ssrf.ts prueft deshalb vor jedem ausgehenden Aufruf:
- Loopback, Link-Local samt Metadaten-Endpunkt, unspezifizierte Adressen,
  Multicast und CGNAT sind gesperrt
- das private LAN bleibt erlaubt, weil dort ueblicherweise die eigene
  Gitea-Instanz steht; abschaltbar ueber GIT_BLOCK_PRIVATE
- GIT_ALLOW_LOOPBACK fuer eine Instanz auf demselben Rechner,
  GIT_ALLOWED_HOSTS als strengster Modus
- Hostnamen werden aufgeloest und *jede* zurueckgegebene Adresse geprueft,
  sonst genuegte ein zweiter A-Record fuer DNS-Rebinding
- nach einem Redirect wird das tatsaechliche Ziel erneut geprueft

Dazu:
- rateLimit.ts, ein Zaehler im Arbeitsspeicher mit vorkonfigurierten
  Kontingenten fuer Anmeldung, ausgehende Aufrufe und WebAuthn
- request.ts liest JSON-Bodies nur mit Content-Type application/json und
  hoechstens 256 KB. Die Content-Type-Pflicht ist zugleich CSRF-Schutz in
  der Tiefe: ein Cross-Site-Formular kommt ohne Preflight nur mit
  text/plain, multipart oder urlencoded durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00
florian.wildeandClaude Opus 5 950bca1b81 fix(ui): Komponentenklassen in @layer components verschieben
Im Suchfeld lag das Lupensymbol auf dem Platzhaltertext. Ursache war die
Kaskade: .field steht mit der Kurzschreibweise "padding: .6rem .85rem" im
Stylesheet hinter Tailwinds Utilities, also gewann sie gegen "pl-9" und das
linke Padding blieb bei 13,6 Pixeln - zu wenig fuer das Symbol.

Die wiederkehrenden Bausteine liegen jetzt in einer eigenen Datei und dort in
@layer components. Tailwind sortiert die Layer als theme, base, components,
utilities; damit gewinnen Utilities wieder gegen die Komponentenklasse.

Das behebt nicht nur die Ueberlappung: auch alle Schaltflaechen mit
"btn ... px-2.5 py-1.5" wurden bisher in der Standardgroesse gerendert, weil
.btn ebenfalls eine padding-Kurzschreibweise setzt. Sie sind jetzt so
kompakt wie vorgesehen.

Geprueft am gebauten Stylesheet: .field liegt an Position 11107 im
components-Layer, .pl-9 an 31324 - die Utility gewinnt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00
florian.wildeandClaude Opus 5 8d26a6d08c docs: getestete Versionen und Hinweis zur Prisma-Warnung ergaenzen
Haelt fest, womit beide Betriebsarten tatsaechlich verifiziert wurden, und
erklaert die Deprecation-Warnung von Prisma 6.19 samt Grund, warum die
Umstellung auf prisma.config.ts noch aussteht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:52 +02:00
florian.wildeandClaude Opus 5 3de886c5e1 chore: ungenutzte experimental.serverActions-Option entfernen
Die Anwendung nutzt ausschliesslich Route Handler, keine Server Actions. Die
Option bewirkte nichts, liess Next beim Build aber "Experiments (use with
caution)" ausgeben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
florian.wildeandClaude Opus 5 220298f50d fix(docker): Prisma-CLI im Laufzeit-Image lauffaehig machen
Der App-Container brach beim Start mit "Cannot find module 'effect'" ab und
lief in die Wiederholschleife des Entrypoints. Ursache: aus dem Builder
wurden nur node_modules/prisma und node_modules/@prisma uebernommen. Die CLI
laedt aber @prisma/config, und das zieht einen eigenen Abhaengigkeitsbaum
(effect, c12, …) nach, der ausserhalb dieser beiden Verzeichnisse liegt.

Statt einzelne Pakete nachzukopieren bekommt die CLI jetzt eine eigene,
minimale Installation unter /opt/prisma-cli. Die Version wird aus der
package.json des Builders gelesen, damit CLI und generierter Client nicht
auseinanderlaufen. Der Entrypoint bricht ausserdem mit klarer Meldung ab,
wenn die CLI fehlt, statt fuenfzehnmal auf eine vermeintlich nicht bereite
Datenbank zu warten.

Verifiziert: docker compose --profile full up -d --build spielt die
Migrationen ein, der Container meldet sich als healthy und erreicht die
Git-APIs nach aussen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
florian.wildeandClaude Opus 5 be9347e01f chore: package-lock.json ergaenzen und next-env.d.ts aktualisieren
Der Lockfile fixiert die tatsaechlich aufgeloesten Versionen (Next 15.5.21,
Prisma 6.19.3, React 19) und ist Voraussetzung fuer "npm ci" im
Docker-Build. next-env.d.ts wurde beim ersten Build von Next um die
Routen-Typen ergaenzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
florian.wildeandClaude Opus 5 6a792623d5 refactor: ungenutzte StatusBadge-Komponente entfernen
Board und Detailseite benutzen durchgaengig StatusSelect, das die Pille
bereits rendert und zusaetzlich den Wechsel erlaubt. Die reine Anzeige-
variante hatte damit keinen Aufrufer mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:45 +02:00
florian.wildeandClaude Opus 5 cb4dbfdf9f docs: README und Funktionsuebersicht
README als Einstieg: was Nebula ist, drei Wege zur Inbetriebnahme
(Standalone, komplett Docker, eigene PostgreSQL), Tabelle aller
Umgebungsvariablen, Anleitung fuer die Git-Verbindung inklusive der noetigen
Token-Scopes je System, Projektstruktur, npm-Skripte, API-Uebersicht,
Sicherheitshinweise und eine Fehlersuchtabelle.

FEATURES.md listet den Funktionsumfang vollstaendig nach Bereichen auf:
Projekte und Status, Dashboard, Notizen, Git-Anbindung inklusive der beim
Sync geholten Daten, Oberflaeche sowie Datenmodell, Sicherheit und Betrieb.
Am Ende stehen bewusst nicht umgesetzte Ideen fuer spaeter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:14 +02:00
florian.wildeandClaude Opus 5 c781c5d19d build: Docker-Betrieb und Standalone-Betrieb gleichwertig unterstuetzen
Dieselbe Codebasis laeuft nativ auf dem Rechner oder komplett im Container.

docker-compose.yml mit Profilen statt mehrerer Dateien:
- "db" laeuft immer  -> docker compose up -d db, App dann lokal per npm
- --profile full     -> zusaetzlich der App-Container
- --profile tools    -> Adminer als Datenbankoberflaeche

Dockerfile als Multi-Stage-Build auf dem Next-Standalone-Output. In die
Laufzeitschicht wandern zusaetzlich Prisma-CLI, Engines und Schema, damit der
Entrypoint beim Start "migrate deploy" ausfuehren kann. Das Alpine-Image
bekommt openssl fuer die Prisma-Engines, der Client wird zusaetzlich fuer
linux-musl-openssl-3.0.x erzeugt. Der Container laeuft als unprivilegierter
Benutzer und meldet seinen Zustand ueber /api/health.

Der Entrypoint wartet in einer Schleife auf die Datenbank, ergaenzend zum
pg_isready-Healthcheck und der depends_on-Bedingung.

scripts/setup.mjs legt die .env aus der Vorlage an und erzeugt dabei ein
zufaelliges APP_SECRET, damit die Tokens nicht mit dem Standardschluessel
verschluesselt werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:04 +02:00
florian.wildeandClaude Opus 5 72674dee4e feat(ui): Verwaltung der Git-Verbindungen
Eine Seite fuer alle angebundenen Git-Systeme. Mehrere Verbindungen desselben
Typs sind moeglich, etwa privates GitHub und Firmen-Gitea nebeneinander.

- Anlegen mit Systemauswahl, Basis-URL und Access Token; die Basis-URL wird
  beim Wechsel des Systems auf den passenden Standard gesetzt, sofern sie
  nicht von Hand geaendert wurde
- Verbindungstest auf Knopfdruck: prueft Erreichbarkeit und Token, meldet den
  erkannten Benutzer und bei Gitea die Serverversion. Ergebnis und Zeitpunkt
  werden gespeichert und in der Karte angezeigt
- Token erscheint nur als Hinweis; ein leeres Feld beim Bearbeiten laesst ihn
  unveraendert
- Loeschen trennt nur die Verknuepfung, die Projekte bleiben erhalten
- Hinweis auf die noetigen Scopes samt Link zur Doku des jeweiligen Systems

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:51 +02:00
florian.wildeandClaude Opus 5 826706aeca feat(ui): Projektdetail mit Notizen und Git-Panel
Kopfbereich mit Statusauswahl samt Erklaerung je Zustand, Favorit,
Fortschrittsregler, Bearbeiten und Loeschen.

Notizen:
- beliebig viele je Projekt, Titel optional, Zeilenumbrueche bleiben erhalten
- anpinnen, bearbeiten, loeschen direkt in der Liste
- "bearbeitet vor ..." erscheint nur bei echter Aenderung, nicht direkt nach
  dem Anlegen

Git-Panel:
- Kennzahlen zu Stars, Forks, offenen PRs/MRs, Issues, Branches und Groesse
- letzter Commit und aktuelle Version als eigene Kacheln
- Sprachverteilung als Balken
- Reiter fuer Branches, Tags, Commits und Releases mit Links ins Repo
- "Jetzt synchronisieren" holt den Stand; Fehler erscheinen im Klartext statt
  als Fehlerseite

Ohne verknuepftes Repository zeigt das Panel einen Hinweis statt leerer
Kacheln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:40 +02:00
florian.wildeandClaude Opus 5 62b5c5bfa6 feat(ui): Dashboard mit Board, Filtern und direktem Statuswechsel
- Fuenf Kennzahlen ueber dem Board: Projektzahl mit Durchschnittsfortschritt,
  Ideen, aktive Entwicklungen, fertige Projekte, verknuepfte Repos
- Volltextsuche ueber Name, Beschreibung, Tags und Repo-Pfad
- Statusfilter als Chips mit Trefferzaehler, mehrfach kombinierbar
- Sortierung nach Aenderung, Anlage, Name, Fortschritt oder Prioritaet;
  Favoriten stehen immer oben
- Zwei Ansichten: freies Raster oder nach Status gruppiert
- Archiv standardmaessig ausgeblendet, per Schalter sichtbar

Die Projektkarte zeigt Fortschritt, Provider, Default-Branch, letzten Tag,
Alter des letzten Commits, Notizzahl und Sync-Fehler. Status und Favorit sind
direkt auf der Karte aenderbar - beides optimistisch, mit Rollback wenn der
Server ablehnt. Die Karte ist ein "stretched link", damit die Schaltflaechen
darin nicht in einem Anker verschachtelt sind.

Der Anlege-Dialog verlangt nur einen Namen; neue Projekte starten als Idee.
Ist eine Git-Verbindung gewaehlt, laesst sich das Repository aus einer
durchsuchbaren Liste uebernehmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:29 +02:00
florian.wildeandClaude Opus 5 6fcbc04110 feat(ui): Dark-Glassmorphism-Designsystem und Grundlayout
Optischer Rahmen der Anwendung:

- Hintergrund aus drei langsam driftenden Nebeln, feinem Raster und einer
  Rauschtextur gegen Farbbanding
- .glass / .glass-soft als Grundflaechen, .card-glow fuer den Hover-Effekt
  mit Farbverlaufsrand je Projektakzent
- Buttons, Formularfelder und Scrollbars im gleichen Stil
- prefers-reduced-motion schaltet saemtliche Animationen ab

Dazu Kopfzeile mit aktiver Navigationsmarkierung, ein wiederverwendbarer
Modal-Dialog (Escape schliesst, Scroll wird gesperrt), das SVG-Icon sowie
Fehler- und 404-Seiten. Die Fehlerseite erkennt Datenbankprobleme und nennt
direkt die drei Befehle, die in dem Fall meistens helfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:15 +02:00
florian.wildeandClaude Opus 5 7690bc87c5 feat(api): REST-Endpunkte fuer Projekte, Notizen, Verbindungen und Sync
Alles, was die Oberflaeche kann, ist damit auch scriptbar:

  GET  POST           /api/projects
  GET  PATCH DELETE   /api/projects/:id
  POST                /api/projects/:id/sync
  GET  POST           /api/projects/:id/notes
  PATCH DELETE        /api/notes/:id
  GET  POST           /api/connections
  PATCH DELETE        /api/connections/:id
  POST                /api/connections/:id/check
  GET                 /api/connections/:id/repos
  GET                 /api/health

Details:
- Jede Eingabe laeuft durch Zod; Feldfehler kommen als Objekt zurueck, damit
  Formulare sie direkt anzeigen koennen.
- Beim Anlegen werden Owner und Repo-Name aus der URL abgeleitet, falls nicht
  explizit angegeben.
- Ein leeres Token-Feld beim Bearbeiten einer Verbindung laesst das
  gespeicherte Token unveraendert; null entfernt es.
- Verbindungsantworten enthalten nie das Chiffrat, nur einen Hinweis
  wie "ghp_...9f3a".
- /api/health prueft die Datenbank und dient dem Docker-Healthcheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:02 +02:00
florian.wildeandClaude Opus 5 9ea6bb8d36 feat(git): Adapter fuer GitHub, GitLab und Gitea
Ein gemeinsames Interface (GitClient) mit drei Implementierungen. Jeder
Adapter liefert dieselbe normalisierte Struktur, sodass die Oberflaeche die
Unterschiede der APIs nicht kennen muss:

- GitHub  – REST v3, Bearer-Token, funktioniert auch mit Enterprise (/api/v3).
            open_issues_count enthaelt dort PRs, das wird herausgerechnet.
- GitLab  – API v4, PRIVATE-TOKEN, Projekte ueber den URL-kodierten Pfad,
            damit verschachtelte Gruppen funktionieren.
- Gitea   – API v1, token-Header, deckt auch Forgejo ab.

Geholt werden Repo-Eckdaten, Branches, Tags, Commits, Releases, Sprachen und
offene PRs/MRs. Die Teilabfragen laufen parallel; eine fehlschlagende Liste
bricht den restlichen Sync nicht ab.

Weiteres:
- http.ts uebersetzt HTTP-Status in verstaendliche Meldungen (401 Token,
  403 Scopes/Rate-Limit, 404 Pfad) und erzwingt ein Timeout.
- parseRepoUrl versteht https-, ssh- und GitLab-Weboberflaechen-URLs.
- meta.ts haelt die Provider-Metadaten frei von node:crypto, damit
  Client-Komponenten sie importieren koennen.
- sync.ts schreibt das Ergebnis als Snapshot und vermerkt Fehler am Projekt,
  statt eine Ausnahme bis in die Seite durchzureichen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:50 +02:00
florian.wildeandClaude Opus 5 c9e09a2762 feat(core): Verschluesselung, Validierung und gemeinsame Helfer
- crypto.ts: AES-256-GCM fuer die Git-Access-Tokens, Schluessel per scrypt
  aus APP_SECRET abgeleitet. Chiffrat ist versioniert (v1.<iv>.<tag>.<data>),
  damit ein spaeterer Formatwechsel moeglich bleibt. Fehlt APP_SECRET, warnt
  die App und faellt auf einen Entwicklungsschluessel zurueck.
- validation.ts: Zod-Schemas fuer alle Eingaben an der API-Grenze, inklusive
  Umwandlung leerer Strings zu null und Feldfehlern fuer die Oberflaeche.
- status.ts: Metadaten zu Status, Prioritaeten und Akzentfarben an einer
  Stelle, damit Board, Karte und Detailseite nicht auseinanderlaufen.
- utils.ts: Slug-Erzeugung, deutsche Datumsformate mit fester Zeitzone
  (verhindert Abweichungen zwischen Server-Render und Hydration) und
  relative Zeitangaben.
- projects.ts: wiederverwendete Prisma-Selects. Die Verbindungsauswahl laesst
  tokenCipher bewusst weg, damit kein Token in eine Client-Komponente gelangt.
- db.ts: Prisma-Client mit Cache ueber globalThis gegen Verbindungslecks beim
  Hot Reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:35 +02:00
florian.wildeandClaude Opus 5 58c493ddbd feat(db): Prisma-Datenmodell fuer Projekte, Notizen und Git-Verbindungen
Vier Tabellen in PostgreSQL:

- Project: Stammdaten, Status, Prioritaet, Fortschritt, Tags, Akzentfarbe
  und die optionale Repo-Verknuepfung
- Note: beliebig viele Notizen je Projekt, anpinnbar, Cascade-Delete
- GitConnection: ein Eintrag je Git-System inkl. verschluesseltem Token
- RepoSnapshot: zwischengespeicherter Repo-Stand, damit das Dashboard
  auch ohne Netz oder bei erreichtem Rate-Limit Daten zeigt

ProjectStatus deckt den Lebenszyklus von der Idee bis zum Archiv ab.
Branch-, Tag- und Commit-Listen liegen als JSONB, damit providerspezifische
Felder nicht verloren gehen.

Enthaelt die Initial-Migration (haendisch gepflegt, damit "prisma migrate
deploy" im Container ohne Shadow-Datenbank durchlaeuft) und einen
idempotenten Seed mit drei Beispielprojekten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:23 +02:00
242 changed files with 41006 additions and 13 deletions
+114
View File
@@ -0,0 +1,114 @@
# ─────────────────────────────────────────────────────────────
# Nebula – Beispielkonfiguration
# Kopieren nach ".env" und Werte anpassen.
# (Die .env wird sowohl von Next.js als auch von docker compose gelesen.)
# ─────────────────────────────────────────────────────────────
# ── Anwendung ────────────────────────────────────────────────
# Anzeigename: Seitentitel, Kopfzeile und Name im Passkey-Dialog.
APP_NAME="Nebula"
# Kleine Zeile unter dem Namen in der Kopfzeile.
APP_TAGLINE="Vibe Coding"
# Oeffentliche Adresse der Instanz – genau so, wie du sie im Browser aufrufst.
#
# WICHTIG fuer Passkeys: Daraus werden die WebAuthn-Relying-Party-ID (der
# Hostname) und der erwartete Origin abgeleitet. Passt der Wert nicht zur
# aufgerufenen Adresse, lehnt der Browser die Anmeldung ab. Passkeys brauchen
# ausserdem einen sicheren Kontext: https:// – oder http://localhost.
#
# Wird die rpID spaeter geaendert (anderer Hostname), sind bestehende Passkeys
# ungueltig und muessen neu angelegt werden. Passwoerter bleiben davon unberuehrt.
APP_URL="http://localhost:3000"
# Port innerhalb des Containers bzw. im Standalone-Betrieb
PORT="3000"
# Host-Port-Mapping fuer den App-Container (--profile full). Nur auf dem
# Loopback – nach draussen geht es ueber einen Reverse Proxy. Soll die
# Instanz im LAN erreichbar sein, hier "3000" eintragen.
APP_PORT="127.0.0.1:3000"
# ── Datenbank ────────────────────────────────────────────────
# Standalone-Betrieb: App laeuft lokal, Postgres via "npm run docker:db"
# oder als nativ installierter Dienst.
DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
# Im Docker-Vollbetrieb (--profile full) setzt compose DATABASE_URL selbst
# auf den Service-Hostnamen "db" – der Wert oben wird dann nicht verwendet.
# Zugangsdaten des Postgres-Containers aus docker-compose.yml
POSTGRES_USER="nebula"
POSTGRES_PASSWORD="nebula"
POSTGRES_DB="nebula"
# Host-Port der Datenbank. Wirkt NUR zusammen mit docker-compose.dev.yml
# (npm run docker:db) – die Basisdatei veroeffentlicht keinen DB-Port, weil
# die Anwendung im Container "db:5432" anspricht und nichts davon nach
# aussen muss. Auf einem Server bleibt dieser Wert also folgenlos.
POSTGRES_PORT="127.0.0.1:5432"
# Host-Port fuer Adminer (--profile tools)
ADMINER_PORT="127.0.0.1:8080"
# ── Betrieb ohne veroeffentlichte Ports ──────────────────────
# Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
# Reverse Proxy den Zugang, veroeffentlicht Nebula gar keinen Port. Dann diese
# beiden Zeilen aktivieren – danach genuegt ueberall "docker compose up -d":
# COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
# COMPOSE_PROFILES="full"
#
# Name des geteilten Netzes und der Alias, unter dem der Proxy Nebula findet.
# PROXY_NETWORK="proxy"
# PROXY_ALIAS="nebula"
# ── Sicherheit ───────────────────────────────────────────────
# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM).
# Erzeugen mit: openssl rand -base64 32
# oder: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# oder: npm run setup (legt die .env inkl. Schluessel an)
# WICHTIG: Wird der Wert nachtraeglich geaendert, sind gespeicherte Git-Tokens
# nicht mehr entschluesselbar und muessen neu hinterlegt werden. Anmeldungen
# und Passkeys sind davon nicht betroffen.
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Absolute Lebensdauer einer Anmeldung in Tagen – ab dem Anmelden gerechnet
# und nicht verlaengerbar.
SESSION_TTL_DAYS="30"
# Leerlauffrist in Stunden: so lange darf eine Sitzung unbenutzt bleiben.
# Wird bei jeder Nutzung neu angestossen. 0 schaltet die Pruefung ab.
SESSION_IDLE_HOURS="8"
# ── Benachrichtigungen ───────────────────────────────────────
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
# Anwendung unter "Mein Konto" eingerichtet.
#
# E-Mail steht erst zur Verfuegung, wenn SMTP_HOST gesetzt ist:
# SMTP_HOST="smtp.example.com"
# SMTP_PORT="587"
# SMTP_SECURE="false" # true bei Port 465, sonst STARTTLS
# SMTP_USER="nebula@example.com"
# SMTP_PASSWORD="geheim"
# SMTP_FROM="Nebula <nebula@example.com>"
# Timeout fuer das Zustellen in Millisekunden
NOTIFY_TIMEOUT_MS="10000"
# ── Ausgehende Git-Zugriffe ──────────────────────────────────
# Timeout fuer Git-API-Aufrufe in Millisekunden
GIT_API_TIMEOUT_MS="15000"
# Schutz vor Server Side Request Forgery: die Basis-URL einer Git-Verbindung
# ist eine Benutzereingabe und wird vom Server abgerufen.
#
# Voreinstellung: Loopback, Link-Local (169.254.x, Cloud-Metadaten), Multicast
# und CGNAT sind gesperrt; das private LAN ist erlaubt, weil dort ueblicherweise
# die eigene Gitea-Instanz steht.
# Gitea laeuft auf demselben Rechner wie Nebula? Dann auf true setzen.
# GIT_ALLOW_LOOPBACK="false"
# Kein Zugriff ins private Netz (10/8, 172.16/12, 192.168/16, fc00::/7)?
# GIT_BLOCK_PRIVATE="false"
# Strengster Modus: nur diese Hosts sind erlaubt, alles andere wird geblockt.
# Eine Angabe hier hebt die beiden Schalter darueber auf.
# GIT_ALLOWED_HOSTS="github.com,gitlab.com,git.meine-firma.de"
+78
View File
@@ -0,0 +1,78 @@
# Prueft jeden Push und jeden Pull Request.
#
# Die Datei liegt unter .gitea/workflows und wird von Gitea Actions gelesen.
# Die Syntax ist mit GitHub Actions kompatibel – wer das Repo dorthin
# spiegelt, kopiert sie nach .github/workflows.
#
# Voraussetzung: In der Gitea-Instanz muessen Actions aktiviert sein
# (app.ini: [actions] ENABLED = true) und mindestens ein Runner registriert.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
pruefen:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: nebula
POSTGRES_PASSWORD: nebula
# Der Name muss "test" enthalten – global-setup.ts leert die
# Datenbank und weigert sich bei allem anderen.
POSTGRES_DB: nebula_e2e_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U nebula"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
E2E_DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
# Nur fuer den Lauf – keine echten Daten im Spiel.
APP_SECRET: ci-lauf-schluessel-nur-fuer-die-pipeline-0123456789
NEXT_TELEMETRY_DISABLED: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Abhaengigkeiten
run: npm ci
- name: Typen pruefen
run: npx tsc --noEmit
- name: Unit-Tests
run: npm test
- name: Browser fuer die Oberflaechentests
run: npx playwright install --with-deps chromium
# Baut zuerst und startet den Server dann selbst (siehe
# playwright.config.ts, webServer).
- name: Ende-zu-Ende-Tests
run: npm run test:e2e
- name: Bericht bei Fehlschlag sichern
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-bericht
path: |
playwright-report/
test-results/
retention-days: 7
+9
View File
@@ -43,6 +43,15 @@ yarn-error.log*
/data /data
/postgres-data /postgres-data
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
/backups
# Playwright
/test-results
/playwright-report
/blob-report
/playwright/.cache
# editors # editors
.idea/ .idea/
.vscode/* .vscode/*
+141
View File
@@ -0,0 +1,141 @@
# Nebula – Hinweise für Claude Code
Dashboard für Vibe-Coding-Projekte. Next.js 15 (App Router), TypeScript,
Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**.
Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) ·
Betrieb: [README.md](README.md) · Server: [DEPLOYMENT.md](DEPLOYMENT.md)
## Womit man arbeitet
```bash
npm run docker:db # Postgres, muss laufen (mit dem dev-Overlay,
# sonst hat die DB keinen Host-Port)
npm run dev # Entwicklung
npm test # Unit-Tests (Vitest, ohne DB)
npx tsc --noEmit # Typprüfung
npm run build # Produktionsbau
docker compose --profile full up -d --build # alles im Container
```
**Vor dem `npm run build` einen laufenden `npm start`/`npm run dev` beenden.**
Sonst hält der Prozess die Prisma-Engine-DLL und der Bau bricht unter Windows
mit `EPERM … query_engine-windows.dll.node` ab.
## Nicht verhandelbar
**Datentrennung.** Jede Abfrage filtert auf den Besitzer, und zwar an erster
Stelle in der `where`-Klausel. Schreiben und Löschen laufen über
`updateMany`/`deleteMany` mit `ownerId`, damit fremde Datensätze gar nicht
erst getroffen werden. Fremde IDs liefern **404, nicht 403** — die Antwort
soll nicht bestätigen, dass es den Datensatz gibt.
**Jeder Endpunkt prüft selbst.** `requireUserApi()` bzw. `requireUserPage()`
sind die Sicherheitsgrenze. Die Middleware schaut nur, ob überhaupt ein
Cookie mitkommt — im Edge-Runtime gibt es keine Datenbank.
**Eingaben validieren.** Zod an jeder API-Grenze, Body über `readJsonBody`
(erzwingt `application/json` und begrenzt die Größe).
**Keine Geheimnisse nach außen.** Git-Tokens verlassen den Server nie;
Verbindungen werden über `connectionSafeSelect` geladen. Projekt-Exporte
enthalten grundsätzlich keine Tokens.
**Ausgehende Aufrufe nur über `lib/git/http.ts`** — dort greift der
SSRF-Schutz (`lib/security/ssrf.ts`).
## Fallstricke, die schon zugeschlagen haben
- **`node:`-Module in Client-Komponenten.** `lib/crypto`, `lib/auth/password`,
`lib/git/index` ziehen `node:crypto`/`node:net` nach. Client-Komponenten
importieren stattdessen `lib/git/meta` bzw. `lib/auth/constants`.
- **`instrumentation.ts` wird auch für Edge übersetzt.** Der dynamische Import
muss *innerhalb* von `if (process.env.NEXT_RUNTIME === "nodejs")` stehen,
sonst scheitert der Bau an `node:crypto`.
- **Eigene CSS-Klassen gehören in `app/components.css`** und dort in
`@layer components`. Außerhalb eines Layers gewinnen sie gegen
Tailwind-Utilities — dann greift `pl-9` neben `.field` nicht mehr.
- **Menüs und Dialoge über Portal.** Karten setzen `overflow-hidden` und
`backdrop-filter`; Letzteres erzeugt einen neuen Bezugsrahmen, in dem
`position: fixed` und `z-index` nicht mehr wie erwartet wirken. Dafür gibt
es `components/ui/Popover.tsx` und `components/ui/Modal.tsx`.
- **Overlays brauchen einen deckenden Hintergrund** (`.popover`), nicht
`.glass` — dessen 3,5 % Deckkraft lassen alles durchscheinen.
- **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst
weichen Server-Render und Hydration voneinander ab. Relative Zeiten
bekommen `suppressHydrationWarning`.
## Migrationen
`prisma migrate dev` ist hier nicht benutzbar (interaktiv). Stattdessen:
```bash
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_shadow"
npx prisma migrate diff --from-migrations prisma/migrations \
--to-schema-datamodel prisma/schema.prisma \
--shadow-database-url postgresql://nebula:nebula@localhost:5432/nebula_shadow \
--script --output prisma/migrations/<stempel>_<name>/migration.sql
npx prisma migrate deploy
```
**`--from-migrations`, nicht `--from-schema-datasource`.** Letzteres
vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration
dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch
einmal.
**Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird
bislang in Ruhe gelassen, aber das ist nicht garantiert).
**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den
Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen.
Vorgehen wie in `20260726230000_repositories`: neue Tabelle anlegen, Bestand
per `INSERT … SELECT` übernehmen, dann erst die alten Spalten entfernen.
Danach `migrate diff` erneut laufen lassen — kommt „empty migration“ heraus,
stimmt das Ergebnis mit dem Schema überein. Und gegen eine Kopie mit echten
Daten prüfen, nicht nur gegen eine leere Datenbank.
## Prüfen, nicht behaupten
Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`.
Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
Dialogzustand ist eine eigene Prüfung — geschlossene Dialoge stehen nicht im
DOM. Der Kontrast gedämpfter Texte ist der Punkt, der hier schon einmal
gerissen wurde; die aufgehellten Stufen stehen in `globals.css` im `@theme`.
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
sich nativ ab. Das sieht aus wie ein Anwendungsfehler, ist aber keiner:
```bash
cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/
```
`pkill` gibt es in dieser Git-Bash nicht. Prozesse über PowerShell beenden
(`Get-CimInstance Win32_Process … | Stop-Process`) — sonst läuft der alte
Server weiter, hält die Prisma-DLL und blockiert den nächsten Bau.
**Niemals gegen die echte Datenbank testen.** Eine Wegwerf-Datenbank anlegen
und danach löschen:
```bash
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
DATABASE_URL=postgresql://nebula:nebula@localhost:5432/nebula_test npx prisma migrate deploy
```
## Sprache
Code-Kommentare, Commit-Nachrichten und Oberfläche auf Deutsch, **ohne
Umlaute in Bezeichnern und Commit-Nachrichten** (bestehende Konvention).
In Markdown-Dateien und in der Oberfläche sind Umlaute erwünscht.
Commits: Conventional Commits, Betreff deutsch, Rumpf erklärt **warum** —
nicht was der Diff ohnehin zeigt.
+412
View File
@@ -0,0 +1,412 @@
# Nebula auf einem Server betreiben
Schritt für Schritt: von der leeren Maschine bis zur laufenden Instanz hinter
TLS — und wie Updates danach ablaufen.
Für Installation zum Ausprobieren und die Entwicklungsvarianten siehe
[README.md](README.md). Hier geht es um den Dauerbetrieb.
---
## Was du brauchst
| | |
| --- | --- |
| Server | Linux mit Docker Engine ≥ 24 und Compose v2 |
| Sonst | `git`, `curl`, eine POSIX-Shell — **kein Node, kein npm** |
| Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB |
| Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank |
| Domain | Eine (Unter-)Domain, die auf den Server zeigt |
| TLS | Pflicht, wenn du Passkeys willst — siehe Schritt 6 |
> **Warum TLS Pflicht ist:** Passkeys verlangen einen sicheren Kontext.
> Über `http://` an einer LAN-IP funktionieren sie nicht. Passwort und
> Zwei-Faktor gehen auch ohne, aber dann läuft dein Passwort im Klartext
> übers Netz — betreibe Nebula nicht ohne TLS.
---
## 1 · Docker installieren
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker # oder ab- und wieder anmelden
docker --version && docker compose version
```
## 2 · Nebula holen
```bash
sudo mkdir -p /opt/nebula && sudo chown "$USER" /opt/nebula
git clone https://git.example.com/dein-name/nebula.git /opt/nebula
cd /opt/nebula
```
## 3 · Konfigurieren
```bash
./scripts/setup.sh # legt .env an, würfelt ein APP_SECRET, setzt Rechte 600
```
Das Skript ist reines POSIX-Shell und braucht **kein Node** — auf einem
reinen Docker-Host ist keins installiert. Es nimmt `openssl`, falls
vorhanden, sonst `/dev/urandom`.
Ganz von Hand geht auch:
```bash
cp .env.example .env
chmod 600 .env
openssl rand -base64 32 # den Wert als APP_SECRET eintragen
```
Dann die `.env` öffnen und mindestens diese drei Werte anpassen:
```env
# Die Adresse, unter der du Nebula im Browser aufrufst.
# Bestimmt die WebAuthn-rpID — passt sie nicht, scheitern Passkeys.
APP_URL="https://nebula.deine-domain.de"
# Verschlüsselt die Git-Tokens. Lang, zufällig, niemals teilen.
APP_SECRET="…dein-langer-zufallswert…"
# Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung.
POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…"
```
Die Ports müssen nicht angefasst werden: `APP_PORT` steht schon auf
`127.0.0.1:3000`, und die Datenbank veröffentlicht gar nichts.
> **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar.
> Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys
> bleiben unberührt.
>
> **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil
> sie an die rpID gebunden sind. Passwörter funktionieren weiter.
### Welche Ports überhaupt hinausgehen
Nach einer regulären Installation ist es **genau einer**:
| Container | Host-Port | Wann |
| --- | --- | --- |
| `nebula-app` | `APP_PORT`, Vorgabe `127.0.0.1:3000` | `--profile full` |
| `nebula-db` | — | **nie** |
| `nebula-adminer` | `ADMINER_PORT`, Vorgabe `127.0.0.1:8080` | nur `--profile tools` |
**Die Datenbank veröffentlicht nichts.** Im Vollbetrieb spricht die Anwendung
Postgres über das interne Docker-Netz als `db:5432` an — die `DATABASE_URL`
setzt Compose selbst. Auch `scripts/backup.sh` und `restore.sh` gehen über
`docker exec`, nicht über den Host. Ein Host-Port wäre nur eine zusätzliche
Angriffsfläche und würde mit anderen Postgres-Containern auf demselben Host
um den Port streiten.
Wer die Datenbank beim Entwickeln doch vom Host aus braucht — `npm run dev`
läuft ja außerhalb des Containers — nimmt `docker-compose.dev.yml` dazu
(`npm run docker:db`). Auf einem Server hat diese Datei nichts zu suchen.
Auch der Port der Anwendung liegt auf `127.0.0.1`: nach draußen geht es über
den Reverse Proxy aus Schritt 6.
### Variante: gar keinen Port veröffentlichen
Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
Reverse Proxy den Zugang, braucht Nebula überhaupt keinen Host-Port. Dafür
gibt es **`docker-compose.server.yml`**: es nimmt die letzte verbliebene
Freigabe zurück und hängt die Anwendung in ein geteiltes Netz.
```bash
docker network create proxy # falls der Proxy-Stack es nicht schon anlegt
```
```env
# .env
COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
COMPOSE_PROFILES="full"
PROXY_NETWORK="proxy" # so heißt das Netz
PROXY_ALIAS="nebula" # so findet der Proxy die Anwendung
```
Compose liest beides aus der `.env`. Danach genügt überall — auch in
`scripts/update.sh` — ein schlichtes `docker compose up -d`. Im Proxy zeigt
das Ziel dann nicht mehr auf `127.0.0.1:3000`, sondern auf **`nebula:3000`**;
Schritt 6 zeigt beide Schreibweisen.
> Ohne die Datei würde ein Override die Portlisten **zusammenhängen** statt
> sie zu ersetzen — Compose führt `ports` anfügend zusammen. Das `!reset`
> darin ist genau dafür da und braucht Compose 2.24 oder neuer.
>
> Das Trennzeichen in `COMPOSE_FILE` ist unter Linux der Doppelpunkt, unter
> Windows das Semikolon.
## 4 · Starten
```bash
docker compose --profile full up -d --build
```
Der erste Bau dauert ein paar Minuten. Der Container spielt beim Start
selbst die Migrationen ein und wartet dabei auf die Datenbank.
```bash
docker compose --profile full logs -f app # mitlesen, Strg+C beendet nur das Lesen
curl -s localhost:3000/api/health # {"status":"ok","database":"up",…}
```
Ohne veröffentlichten Port geht die Frage an den Container statt an den Host
— das Abbild bringt einen `HEALTHCHECK` mit:
```bash
docker inspect --format '{{.State.Health.Status}}' nebula-app # healthy
```
## 5 · Ersten Zugang anlegen
Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder
vorübergehend über einen SSH-Tunnel:
```bash
ssh -L 3000:127.0.0.1:3000 benutzer@server
# dann lokal http://localhost:3000/setup öffnen
```
Dort legst du das Administratorkonto an und wählst die Betriebsart.
**Danach ist `/setup` dauerhaft gesperrt.**
Anschließend gleich unter *Mein Konto*:
1. **Passkey hinzufügen** — geht erst, wenn TLS steht
2. **Zwei-Faktor einrichten** und die Wiederherstellungscodes wegschreiben
## 6 · Reverse Proxy mit TLS
Nebula bringt keinen eigenen TLS-Abschluss mit. Beispiel mit **Caddy**, das
Zertifikate von selbst besorgt und erneuert:
```caddyfile
# /etc/caddy/Caddyfile
nebula.deine-domain.de {
reverse_proxy 127.0.0.1:3000
}
```
```bash
sudo systemctl reload caddy
```
Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei
Let's Encrypt und leitet `http` auf `https` um.
Läuft der Proxy selbst als Container im geteilten Netz (die Variante ohne
veröffentlichte Ports), lautet das Ziel stattdessen `nebula:3000` —
Docker löst den Alias auf:
```caddyfile
nebula.deine-domain.de {
reverse_proxy nebula:3000
}
```
<details>
<summary>Dasselbe mit nginx</summary>
```nginx
server {
listen 443 ssl http2;
server_name nebula.deine-domain.de;
ssl_certificate /etc/letsencrypt/live/nebula.deine-domain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nebula.deine-domain.de/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Zertifikat mit `certbot --nginx -d nebula.deine-domain.de`.
</details>
> Steht `APP_URL` auf `https://…`, setzt Nebula das `Secure`-Flag auf die
> Sitzungscookies. Läuft der Proxy auf `https`, die `APP_URL` aber noch auf
> `http`, funktioniert die Anmeldung — nur ohne dieses Flag. Also beides
> gleichziehen.
## 7 · Firewall
```bash
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enable
```
Port 3000 bleibt **zu**, 5432 wird ohnehin nie veröffentlicht. Die Firewall
ist hier der zweite Riegel, nicht der erste.
> Docker schreibt seine Portfreigaben direkt in `iptables` und geht dabei an
> `ufw` **vorbei**. Eine Freigabe auf `0.0.0.0` steht also im Netz, auch wenn
> `ufw` sie zu melden scheint. Genau deshalb wird hier an `127.0.0.1`
> gebunden, statt sich auf die Firewall zu verlassen.
## 8 · Sicherungen automatisieren
`scripts/backup.sh` legt einen `pg_dump` unter `backups/` ab. Täglich per
Cron:
```bash
crontab -e
```
```cron
# Jede Nacht um 3:15 sichern, 30 Tage aufheben
15 3 * * * cd /opt/nebula && ./scripts/backup.sh >> /var/log/nebula-backup.log 2>&1
30 3 * * * find /opt/nebula/backups -name '*.sql.gz' -mtime +30 -delete
```
Zurückspielen:
```bash
./scripts/restore.sh backups/nebula-2026-07-27-03-15-00.sql.gz
```
> Die Sicherungen liegen auf **demselben Server**. Das hilft gegen einen
> verpatzten Import, nicht gegen einen Plattenausfall. Zieh sie regelmäßig
> woandershin — `rsync`, `restic`, was du ohnehin nutzt.
---
## Updates
Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen
Reihenfolge und bricht ab, sobald ein Schritt scheitert:
```bash
cd /opt/nebula
./scripts/update.sh
```
Was es tut:
| Schritt | |
| --- | --- |
| **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? |
| | *(alles ohne Node — das Skript liest die Version selbst aus der `package.json`)* |
| **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach |
| **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung |
| **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen |
| **5 Starten** | Container neu; die Migrationen laufen im Entrypoint |
| **6 Prüfen** | Bis zu zwei Minuten warten, bis der Container sich gesund meldet |
Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und
nennt den Weg zurück.
```bash
./scripts/update.sh --yes # ohne Rückfrage, für Cron
./scripts/update.sh --ref v1.4.0 # auf eine bestimmte Fassung
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
```
### Wenn doch etwas schiefgeht
```bash
# Zurück auf den vorigen Stand (die Kennung nennt das Skript beim Abbruch)
git checkout <kennung>
./scripts/update.sh --no-pull --yes
# Nur wenn eine Migration die Daten beschädigt hat:
./scripts/restore.sh backups/<die-sicherung-von-vorhin>.sql.gz
```
> **Migrationen werden nicht zurückgerollt.** Eine Migration, die eine Spalte
> entfernt, ist nicht umkehrbar — deshalb sichert das Skript vorher. Steht
> ein Update mit Datenmigration an, lies vorher den Änderungsverlauf
> (Versionsnummer in der Fußzeile).
---
## Betrieb
### Zustand ansehen
```bash
docker compose --profile full ps
docker compose --profile full logs -f app
curl -s localhost:3000/api/health
```
### Neu starten
```bash
docker compose --profile full restart app
```
### Speicher- und Plattenverbrauch
```bash
docker stats --no-stream nebula-app nebula-db
docker system df
docker image prune -f # alte Abbilder nach mehreren Updates
```
### Automatischer Abgleich
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
Automatischer Abgleich*: Abstand, Repositories je Durchlauf, ob fertige
Projekte ausgelassen werden. Der Zeitgeber läuft im Serverprozess selbst —
kein zusätzlicher Container, kein Cron.
Wer lieber von außen taktet, lässt ihn aus und ruft
`POST /api/admin/sync/run` auf.
---
## Härtung, die schon eingebaut ist
Damit du weißt, worum du dich **nicht** kümmern musst:
- Der Container läuft als **unprivilegierter Benutzer** (`nextjs`, UID 1001),
nicht als root.
- **Content-Security-Policy** mit Nonce, `strict-dynamic` und ohne
`unsafe-inline` für Skripte.
- **CSRF-Prüfung** über `Sec-Fetch-Site`/`Origin` bei jedem schreibenden
Zugriff.
- **SSRF-Schutz** bei allen ausgehenden Aufrufen: jede Adresse wird
aufgelöst und gegen Loopback, Link-Local und Metadaten-Endpunkte geprüft,
Weiterleitungen eingeschlossen.
- **Ratenbegrenzung** auf Anmeldung, ausgehende Abrufe und schreibende
Zugriffe; Kontosperre nach acht Fehlversuchen.
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
verlassen den Server nie — auch nicht im Export.
- Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht,
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
### Was du beisteuern musst
- **TLS** über den Reverse Proxy
- **`APP_SECRET`** lang und zufällig
- **Datenbankport** nicht nach außen öffnen
- **Sicherungen** wegschaffen
- **Systemupdates**: `unattended-upgrades` für das Betriebssystem,
`./scripts/update.sh` für Nebula
---
## Mehrbenutzerbetrieb
Nebula kann beides. Im Einzelbetrieb gibt es genau ein Konto und keine
Registrierung. Umschalten unter *Admin-Einstellungen*.
Wichtig zu wissen: **Projekte sind zwischen Konten hart getrennt.** Auch ein
Administrator sieht fremde Projekte nicht — er verwaltet Konten, keine
Inhalte. Wer gemeinsam an einem Projekt arbeiten will, ist mit Nebula falsch
beraten; das ist [bewusst nicht](ROADMAP.md) vorgesehen.
Die Selbstregistrierung ist standardmäßig aus. Lass sie aus, wenn die
Instanz aus dem Internet erreichbar ist und du nicht willst, dass sich
Fremde Konten anlegen.
+72
View File
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────
# Nebula – Produktions-Image (Next.js standalone + Prisma)
# Build: docker compose --profile full build
# Start: docker compose --profile full up -d
# ─────────────────────────────────────────────────────────────
FROM node:22-alpine AS base
# openssl wird von den Prisma-Engines benoetigt, libc6-compat von Next.js.
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# ── Abhaengigkeiten ──────────────────────────────────────────
FROM base AS deps
COPY package.json package-lock.json* ./
# prisma/ wird schon hier gebraucht, weil "postinstall" prisma generate ausfuehrt.
COPY prisma ./prisma
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ── Build ────────────────────────────────────────────────────
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Zur Build-Zeit wird keine echte Datenbank kontaktiert (alle Seiten sind
# dynamisch), Prisma verlangt die Variable aber beim Generieren.
ENV DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build?schema=public"
RUN npm run build
# ── Laufzeit ─────────────────────────────────────────────────
FROM base AS runner
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# Prisma-CLI fuer "migrate deploy" beim Containerstart. Sie bekommt eine
# eigene, minimale Installation unter /opt: aus dem Builder laesst sie sich
# nicht herausloesen, weil @prisma/config einen ganzen Abhaengigkeitsbaum
# nachzieht (effect, c12, …). Die Version wird aus dem Builder uebernommen,
# damit CLI und generierter Client nicht auseinanderlaufen.
COPY --from=builder /app/node_modules/prisma/package.json /tmp/prisma-cli.json
RUN npm install --prefix /opt/prisma-cli --no-save --no-audit --no-fund \
"prisma@$(node -p "require('/tmp/prisma-cli.json').version")" \
&& rm -f /tmp/prisma-cli.json \
&& npm cache clean --force
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Query-Engine und Schema fuer die Laufzeit bzw. die Migration.
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "server.js"]
+891
View File
@@ -0,0 +1,891 @@
# Funktionsübersicht
Vollständige Auflistung dessen, was Nebula kann — gegliedert nach Bereichen.
Für Installation und Betrieb siehe [README.md](README.md).
---
## 0. Konten und Zugang
### 0.1 Betriebsarten
Beim ersten Aufruf führt `/setup` durch die Einrichtung des Administratorkontos. Dort — und
später jederzeit unter *Admin-Einstellungen* — wird die Betriebsart gewählt:
| Modus | Verhalten |
| --- | --- |
| **Einzelbetrieb** | Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung. |
| **Mehrbenutzerbetrieb** | Beliebig viele Konten mit strikt getrennten Daten. Selbstregistrierung optional zuschaltbar. |
Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein Konto existiert —
sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft gesperrt.
### 0.2 Anmeldung
- **Benutzername und Passwort.** Mindestens 10 Zeichen. Abgelehnt werden naheliegende
Passwörter, reine Ziffernfolgen, zu wenig verschiedene Zeichen (`abababab`),
durchlaufende Folgen (`abcdefgh`) und alles, was den eigenen Benutzernamen enthält.
**Keine Zusammensetzungsregeln** — ASVS 2.1.9 rät ausdrücklich davon ab, weil sie zu
`Passwort1!` führen statt zu Entropie.
- **Hashing mit scrypt**, `N = 2^17, r = 8, p = 1` nach dem OWASP Password Storage Cheat
Sheet (rund 0,5 s und 128 MiB je Prüfung). Die Parameter stehen im Hash, ältere bleiben
also lesbar — und werden bei der nächsten Anmeldung still auf den aktuellen Stand
gehoben. Das ist der einzige Moment, in dem das Klartextpasswort vorliegt.
- **Gleiche Antwort für alles Fehlgeschlagene.** Ob ein Konto existiert, gesperrt,
deaktiviert oder reines Passkey-Konto ist, sagt die Anmeldung erst, wenn das Passwort
stimmt. Davor gibt es genau einen Text und eine vergleichbare Antwortzeit
(`fakeVerifyDelay`), damit sich Benutzernamen nicht durchprobieren lassen.
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
- **Zwei-Faktor per TOTP.** Zeitbasiertes Einmalkennwort zusätzlich zum Passwort, für jede
gängige Authenticator-App. Einrichtung über QR-Code oder abgetippten Schlüssel; scharf
wird er erst nach einem bestätigten Code, damit sich niemand aussperrt, der den Scan
nicht hinbekommt. Dazu zehn **Wiederherstellungscodes**, jeder genau einmal gültig —
sie werden nur bei der Ausgabe angezeigt. Abschalten und Neuerzeugen verlangen das
Passwort, nicht bloß eine offene Sitzung.
- Die Passkey-Anmeldung fragt **nicht** nach dem zweiten Faktor: der Passkey ist bereits an
ein Gerät gebunden und damit selbst der zweite Faktor.
- Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich
dann nicht entfernen, solange kein Passwort gesetzt ist.
- Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten.
- Die Fehlermeldung ist immer dieselbe und die Antwortzeit gleich lang, egal ob es den
Benutzernamen gibt — so lässt sich nicht ausprobieren, welche Konten existieren.
### 0.3 Mein Konto
Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Zwei-Faktor einrichten und
abschalten · Passkeys verwalten · aktive Sitzungen einsehen (Browser, Betriebssystem, IP,
letzte Aktivität) · überall abmelden, wobei das eigene Gerät angemeldet bleibt.
### 0.4 Benutzerverwaltung (Administratoren)
- Konten anlegen, umbenennen, Rolle wechseln, deaktivieren, löschen
- Passwort eines Kontos neu setzen — beendet dessen Sitzungen sofort
- Übersicht je Konto: Projektzahl, Passkeys, letzte Anmeldung, Sperrstatus
- Selbstregistrierung an- und abschaltbar
- Schutzregeln: Der letzte aktive Administrator kann weder herabgestuft, deaktiviert noch
gelöscht werden; das eigene Konto lässt sich nicht löschen oder deaktivieren.
- **Administratoren sehen keine fremden Projekte.** Sie verwalten Konten, nicht Inhalte.
---
## 1. Projekte
### 1.1 Anlegen und Verwalten
- **Idee genügt.** Ein Projekt braucht nur einen Namen. Alles Weitere — Beschreibung,
Repository, Tags — lässt sich jederzeit nachtragen. Neue Projekte starten deshalb
standardmäßig im Status *Idee*.
- **Bearbeiten** über einen Dialog, der auf dem Dashboard und der Projektseite
identisch funktioniert.
- **Löschen** mit Rückfrage; zugehörige Notizen und der Repo-Snapshot verschwinden mit
(Fremdschlüssel mit `ON DELETE CASCADE`).
- **Slug** wird automatisch aus dem Namen erzeugt (umlautfest) und bei Namenskollision
durchnummeriert.
### 1.2 Felder
| Feld | Beschreibung |
| --- | --- |
| Name | Pflichtfeld |
| Kurzbeschreibung | Ein Satz für die Karte im Board |
| Beschreibung | Ausführliche Ideenskizze, Zeilenumbrüche bleiben erhalten |
| Status | Idee · In Planung · Offen · In Entwicklung · Fertig · Archiviert |
| Priorität | Niedrig · Normal · Hoch · Kritisch |
| Fortschritt | 0–100 %, per Regler |
| Akzentfarbe | Sechs Farbverläufe, prägen Karte und Detailseite |
| Tags | Kommagetrennt, bis zu 12 Stück, kleingeschrieben und dedupliziert |
| Favorit | Anpinnen — Favoriten stehen im Board immer oben |
| Git-Verbindung | Optionale Zuordnung zu einem Git-System |
| Repository | URL sowie Owner/Name (werden aus der URL abgeleitet) |
### 1.3 Status
Sechs Zustände, jeder mit eigener Farbe und Erklärung im Auswahlmenü:
| Status | Bedeutung |
| --- | --- |
| **Idee** | Noch ein Gedanke — nichts gebaut, nichts entschieden |
| **In Planung** | Scope, Stack und Schnitt werden festgelegt |
| **Offen** | Startklar, liegt aber gerade still |
| **In Entwicklung** | Wird aktiv gebaut |
| **Fertig** | Ausgeliefert und abgeschlossen |
| **Archiviert** | Ruht — bleibt erhalten, ist im Board aber ausgeblendet |
Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailbereich zu
öffnen. Die Änderung wird optimistisch angezeigt und bei einem Fehler zurückgerollt.
---
## 2. Dashboard
### Schnellerfassung
Ein Blitzsymbol in der Kopfleiste, auf jeder Seite erreichbar. Ein Feld, Enter, fertig —
als **Idee** entsteht ein Projekt im Status `IDEA`, als **Aufgabe** eine Aufgabe im
gewählten Projekt (`TODO`). Alles Weitere setzen die Vorgaben des Schemas.
Der Dialog **bleibt nach dem Anlegen offen** und leert nur das Feld. Wer eine Idee
notiert, hat meistens gleich die nächste; ihn jedes Mal neu zu öffnen wäre genau die
Hürde, die hier wegsoll. Angelegtes wird darunter mit Link bestätigt. Das zuletzt
gewählte Projekt wird gemerkt.
Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten rechts —
dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert.
### Überblick
- **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive
Entwicklungen, fertige Projekte, verknüpfte Repositories. Auf dem Telefon zweispaltig,
damit die Projekte nicht erst nach dem zweiten Wischen beginnen.
- **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad.
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben.
- **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks".
- **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar.
- **Leerzustände** mit direktem Einstieg statt einer leeren Fläche.
### Schnellsuche
**`Strg+K`** (auf dem Mac `Cmd+K`) öffnet überall eine Palette:
- **Projekte** nach Name, Kurzbeschreibung und Tags — ohne Eingabe stehen die letzten
gleich bereit
- **Notizen** über dieselbe Volltextsuche wie im Board, entprellt ab zwei Zeichen
- **Bereiche**: Dashboard, Git-Verbindungen, Mein Konto, Admin-Einstellungen
Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die Projektliste
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
wäre Verschwendung.
### Projektvorlagen
Ein Projekt, das sich bewährt hat, lässt sich im Projektkopf über **Als Vorlage sichern**
festhalten: Status, Priorität, Farbe, Tags, Notizen und offene Aufgaben. Beim Anlegen eines
neuen Projekts steht die Vorlage oben im Dialog zur Auswahl.
Bewusst benutzereigen statt fest eingebaut — welche Tags und welche Startaufgaben sinnvoll
sind, weiß nur, wer damit arbeitet. Eine mitgelieferte Liste wäre geraten.
- **Erledigte Aufgaben kommen nicht mit.** Eine Vorlage beschreibt, was zu tun ist, nicht
was einmal getan wurde.
- **Repositories gehen nicht mit.** Eine Vorlage, die jedes neue Projekt an dasselbe
Repository hängt, wäre eine Falle.
- Die Stammdaten füllt der Dialog **sichtbar** in die Felder, damit sie vor dem Speichern
noch änderbar sind; bereits Getipptes wird nicht überschrieben. Notizen und Aufgaben legt
erst der Server an — im Dialog ließen sie sich nicht darstellen.
- Vorlagen überleben das Löschen ihres Ursprungsprojekts.
### Mehrfachauswahl
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Liste
Eine Zeile je Projekt, alle gleich aufgebaut: Statusfarbe als Streifen, Name,
Fortschrittsbalken mit Prozentwert, danach Aufgaben, Notizen, Branch und Alter des
letzten Commits. Der Statuswechsler sitzt rechts, die ganze Zeile ist ein Link.
Gedacht ist die Ansicht für zwei Fälle:
- **Auf dem Telefon.** Eine Karte braucht rund 260 px Höhe und steht dort zwangsläufig
allein in der Zeile — fünf Projekte füllen zwei Bildschirme, bevor man überhaupt
etwas vergleichen kann. Eine Zeile kommt mit einem Viertel davon aus.
- **Beim Vergleichen.** Weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt
und Status untereinander. Genau das verliert das Raster, sobald es einspaltig wird.
Was nicht mehr in die Zeile passt, fällt der Reihe nach weg, statt sie zu sprengen:
Notizen ab `sm`, Branch ab `md`, der letzte Commit und die Priorität ab `lg`.
### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
- Gezogen wird am Griff links, nicht an der ganzen Karte — so bleibt der Titel ein
normaler Link.
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
Auf Touchgeräten nach kurzem Halten.
- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
### Projektkarte auf einen Blick
Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge ·
Default-Branch samt Anzahl weiterer Branches · letzter Tag · Alter des letzten Commits ·
Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität.
---
## 2a. Aufgaben je Projekt
Jedes Projekt hat ein eigenes Aufgabenbrett mit vier Spalten: **Offen · In Arbeit ·
Blockiert · Erledigt**.
- **Ziehen und Ablegen** zwischen den Spalten **und innerhalb** einer Spalte. Anders als
beim Projektbrett ist die Reihenfolge hier bedeutsam, deshalb wird sie gespeichert.
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den Dialog öffnet.
- Eine Aufgabe braucht nur einen Titel. Optional: Beschreibung (Markdown),
Fälligkeitsdatum und Labels.
- **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen
gedämpft.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht.
### Wiederkehrende Aufgaben
Täglich, wöchentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe nach
**Erledigt** — im Dialog oder durch Ziehen —, entsteht die nächste von selbst. Die
erledigte bleibt stehen, sonst ginge die Historie verloren.
- **Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken.** Eine wöchentliche Aufgabe
bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch erledigt wurde.
- Lange Liegengebliebenes rutscht so weit vor, bis der Termin in der Zukunft liegt — eine
Aufgabe, die schon bei ihrer Entstehung überfällig ist, hilft niemandem.
- Beim Monatsintervall bleibt der Tag im Monat erhalten. Der 31. Januar wird zum
28. Februar, nicht zum 3. März.
- Nur der **Übergang** nach Erledigt löst aus. Eine bereits erledigte Aufgabe erneut zu
speichern erzeugt keine zweite Fassung.
- Bewusst grob gehalten: feinere Regeln („jeden zweiten Dienstag im Monat") braucht ein
Projektdashboard nicht und wären ohne Kalenderansicht kaum nachvollziehbar.
### Aus Git-Issues übernehmen
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
Labels und ein Link zurück ins Git-System.
- Grundlage ist der **zuletzt geholte Stand**, hier wird nichts nachgeladen. Ein Klick
soll nicht ungefragt fremde APIs ansprechen; wer den neuesten Stand will, gleicht
vorher ab.
- Die Issue-Nummer bleibt an der Aufgabe und ist je Projekt eindeutig. Ein zweiter Klick
legt deshalb **keine Dubletten** an, auch wenn die Aufgabe inzwischen verschoben oder
umbenannt wurde.
- Ohne Repo-Stand meldet der Server das mit einem Hinweis statt still nichts zu tun.
### Fortschritt aus Aufgaben
Pro Projekt umschaltbar. Ist es an, ergibt sich der Fortschritt aus dem Anteil erledigter
Aufgaben und wird bei jeder Änderung neu berechnet — der Regler ist dann gesperrt, statt
einen Wert anzubieten, der gleich wieder überschrieben würde.
Auf der Projektkarte im Dashboard steht das Verhältnis als `3/8`.
### Wie die Reihenfolge gespeichert wird
Beim Ablegen schickt die Oberfläche je Spalte die **vollständige Liste** der Aufgaben in
ihrer neuen Reihenfolge; der Server vergibt daraus Position und Status. Das ist
wiederholbar, kommt ohne Positionsrechnerei im Browser aus, und ein verlorener Aufruf
lässt sich einfach erneut senden. IDs, die nicht zum Projekt gehören, werden ignoriert
statt verschoben.
---
## 2b. Aufgaben über alle Projekte
Die Seite `/tasks` sammelt jede Aufgabe aus jedem Projekt an einer Stelle. Erreichbar
über die Kopfleiste und die Schnellsuche.
**Geordnet wird nach Fälligkeit, nicht nach Spalte.** Projektübergreifend lautet die
Frage „was ist überfällig", nicht „was steht wo im Brett". Die Fächer:
| Fach | Inhalt |
| --- | --- |
| **Überfällig** | Termin liegt vor heute |
| **Heute** | Termin ist der heutige Kalendertag |
| **Diese Woche** | in den nächsten sieben Tagen |
| **Später** | alles danach |
| **Ohne Termin** | kein Fälligkeitsdatum gesetzt |
Gerechnet wird auf **Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf
Stunden und nicht nach Serverzeit. Eine heute um 9 Uhr fällige Aufgabe ist um 17 Uhr
nicht überfällig, sondern immer noch „heute" — sonst wanderte die halbe Liste im Lauf
des Tages nach oben. Und im Container läuft die Anwendung nach UTC; ohne feste Zeitzone
kippte die Einteilung abends um einen Tag. Das Fach bestimmt der **Server**, damit
Server-Render und Hydration nicht auseinanderlaufen.
- **Filter** nach Status (mit Zähler) und Projekt; Erledigtes ist ausgeblendet und
einzeln zuschaltbar.
- **Abhaken direkt aus der Liste.** Der Endpunkt erledigt dabei den Rest: Zeitpunkt,
abgeleiteter Projektfortschritt und — bei wiederkehrenden Aufgaben — die nächste
Fassung.
- Jede Zeile nennt ihr **Projekt** und verlinkt es; ohne das ist eine übergreifende
Liste nicht zu lesen. Stammt die Aufgabe aus einem Git-Issue, führt ein Symbol dorthin.
**Bewusst nicht sortierbar per Ziehen.** Die Position einer Aufgabe gilt innerhalb ihrer
Spalte in ihrem Projekt; hier wäre sie ohne Bedeutung. Eine Reihenfolge anzubieten, die
nirgends ankommt, wäre ein Versprechen, das die Seite nicht halten kann.
Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt
(`project: { ownerId }` als einzige und erste Bedingung).
---
## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
- **Markdown wird dargestellt** — Überschriften, Listen, Tabellen, Codeblöcke, Zitate und
Links. Aus `- [ ] Aufgabe` werden echte Checklisten. Gilt auch für die Projekt-
beschreibung. Rohes HTML wird beim Rendern entfernt (`rehype-sanitize`) — der Inhalt
stammt vom Benutzer und wäre sonst ein XSS-Loch.
- **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben.
- **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach.
- **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung.
- Zeilenumbrüche und Einrückungen bleiben erhalten — Checklisten und Code-Schnipsel
überleben das Speichern.
- Eine neue Notiz aktualisiert die Änderungszeit des Projekts, damit die Sortierung
„zuletzt geändert" die tatsächliche Aktivität abbildet.
---
## 4. Git-Anbindung
### 4.0 Mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus mehreren Repositories — Frontend und Backend etwa, oder
App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
- Im Git-Panel steht eine Leiste mit allen Repositories des Projekts, sobald es mehr als
eines gibt. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich; „Alle" gleicht
sie nacheinander ab — nicht gleichzeitig, denn ein Schwung Anfragen an denselben Anbieter
ist genau das, worauf Rate-Limits reagieren.
- Eine frei wählbare **Bezeichnung** („Frontend") tritt an die Stelle von `owner/name`.
- Das erste Repository ist das **führende**. Wo nur eines dargestellt werden kann — auf der
Karte im Dashboard, im Kanban —, ist es dieses. Die Karte zeigt daneben `+2 Repos`.
Ein Abgleichsfehler an *irgendeinem* Repository erscheint auf der Karte, sonst bliebe er
unbemerkt.
- **Stillstand** bemisst sich am jüngsten Commit über alle Repositories: an einem Vorhaben
wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.
- Benachrichtigungen merken sich den gemeldeten Zustand **je Repository**. Sonst verdeckte
ein rotes Frontend die rote Pipeline des Backends. Der Betreff nennt das Repository nur,
wenn es nicht ohnehin so heißt wie das Projekt.
- Übernommene Issues merken sich, aus welchem Repository sie stammen — Frontend und Backend
haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
### 4.0a README-Vorschau
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
READMEs sind eingeklappt, ein Knopf zeigt alles.
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
(Wurzelverzeichnis auflisten, dann die Datei holen).
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
Codeblock stehen bleibt.
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
liefen sie ins Leere.
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
Bereinigung wie Notizen (`rehype-sanitize`).
### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit |
| --- | --- | --- | --- |
| **GitHub** | REST v3 | `Authorization: Bearer` | github.com und GitHub Enterprise (`/api/v3`) |
| **GitLab** | API v4 | `PRIVATE-TOKEN` | gitlab.com und Self-Hosted, verschachtelte Gruppen |
| **Gitea / Forgejo** | API v1 | `Authorization: token` | beliebige eigene Instanz |
Mehrere Verbindungen desselben Typs sind möglich — etwa privates GitHub und
Firmen-Gitea nebeneinander.
### 4.2 Verbindungen verwalten
- Anlegen mit Anzeigename, System, Basis-URL und Access Token.
- **Verbindungstest** auf Knopfdruck: prüft Erreichbarkeit und Token, zeigt den erkannten
Benutzer und bei Gitea zusätzlich die Serverversion. Ergebnis und Zeitpunkt werden
gespeichert und in der Karte angezeigt.
- **Token-Verwaltung**: verschlüsselt gespeichert, in der UI nur als Hinweis (`ghp_…9f3a`)
sichtbar. Beim Bearbeiten bedeutet ein leeres Feld „unverändert lassen".
- **Repository-Auswahl**: Nebula listet alle Repos, auf die der Token Zugriff hat, mit
Suchfeld. Ein Klick übernimmt URL, Owner, Name und — falls noch leer — Projektname und
Kurzbeschreibung.
- **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten.
### 4.3 Was beim Sync geholt wird
| Bereich | Details |
| --- | --- |
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests |
| **Pull-/Merge-Requests** | bis zu 50 offene, mit Nummer, Titel, Autor, Alter, Labels, Entwurfskennzeichnung und Link |
| **Issues** | bis zu 50 offene, gleiche Angaben. Bei GitHub und Gitea werden PRs herausgefiltert, die dort in derselben Liste stecken |
| **CI-Status** | Letzter Lauf auf dem Default-Branch: erfolgreich, fehlgeschlagen, läuft, wartet oder abgebrochen — mit Name und Link |
| Branches | bis zu 50, mit SHA, Default-Markierung, Schutzstatus und (wo die API es liefert) letztem Commit |
| Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
| **Commit-Heatmap** | Ein Feld je Tag über bis zu ein Jahr, auf der Projektseite |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten |
Woher der CI-Status kommt:
| System | Quelle |
| --- | --- |
| GitHub | `actions/runs` für den Default-Branch; Lauf-Status und Ergebnis werden zusammengeführt |
| GitLab | `pipelines` für den Default-Branch |
| Gitea | Kombinierter Commit-Status — deckt Gitea Actions **und** externe Systeme wie Woodpecker ab, anders als ein reiner Actions-Endpunkt, den ältere Instanzen nicht kennen |
Zur Ehrlichkeit der Aktivitätskurve: geholt wird eine Seite mit 100 Commits. Reicht die
nicht über den ganzen Zeitraum, wird die Kurve auf den tatsächlich belegten Ausschnitt
gekürzt — eine Null zu zeichnen, wo wir schlicht nichts wissen, wäre falsch.
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
### 4.4 Automatischer Abgleich
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
| Einstellung | Bedeutung |
| --- | --- |
| Abstand | 15 Minuten bis täglich |
| Repositories je Durchlauf | 1–100, Voreinstellung 25 |
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
Gezählt und begrenzt wird je **Repository**, nicht je Projekt: sonst zöge ein Projekt mit
fünf Repos fünfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt.
Die Daten für die **Heatmap** kommen getrennt und höchstens einmal am Tag — an einem
Jahresüberblick ändert sich von Stunde zu Stunde nichts Sichtbares. Bei GitHub genügt dafür
ein Aufruf der Jahresstatistik; GitLab und Gitea kennen keine, dort werden bis zu fünf
Seiten Commits geholt. Reicht das nicht ein Jahr zurück, **beginnt die Heatmap beim
ältesten bekannten Commit** und sagt es dazu — leere Felder für Zeiträume zu zeichnen, über
die nichts bekannt ist, wäre eine Lüge.
Wie der Durchlauf arbeitet:
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
Cron. Zwei Durchläufe überschneiden sich nie.
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
nächsten Mal.
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
Token blockiert damit nicht dauerhaft das Kontingent.
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
synchronisieren geht unabhängig davon immer.
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
`POST /api/admin/sync/run` per eigenem Cron auf.
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
- **Fehler landen am Projekt** statt in einer Fehlerseite: die Karte zeigt „Sync-Fehler",
die Projektseite die genaue Meldung. Typische HTTP-Codes werden in Klartext übersetzt
(401 Token, 403 Scopes/Rate-Limit, 404 Pfad, 408 Timeout).
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab.
### 4.6 Aufgabe ↔ Git-Issue
Die Verbindung geht in beide Richtungen — mit einer bewussten Grenze.
**Nebula → Git.** Ein Knopf im Aufgabendialog legt zur Aufgabe ein Issue im
verknüpften Repository an. Nummer und Adresse landen an der Aufgabe (`sourceIssue`,
`sourceUrl`, `sourceRepositoryId` — dieselben Felder, die die Übernahme aus Issues seit
v0.9.0 füllt, samt Eindeutigkeit je Repository gegen Dubletten). In den Rumpf kommt ein
Rückverweis auf das Projekt.
Ausdrücklich **per Knopf und nie nebenbei**: was in ein fremdes System geschrieben wird,
soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledigt schließt
das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe
trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern.
**Git → Nebula** auf zwei Wegen, die sich ergänzen:
- **Webhook** — sofort, sobald das Issue geschlossen wird. Setzt voraus, dass die Instanz
von außen erreichbar ist und der Haken eingetragen wurde (siehe 4.7).
- **Beim Abgleich** — verzögert, aber verlässlich. Der Abgleich fragt den Zustand
verknüpfter Issues nach und hakt ab, was in Git geschlossen ist. Damit funktioniert der
Rückweg auch ohne Webhook, ohne öffentliche Erreichbarkeit und selbst dann, wenn ein
Haken einmal ausfällt.
Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen führt,
werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht — die Liste der offenen
Issues ist gedeckelt, aus dem Fehlen einer Nummer folgt also nichts. Pro Durchgang
fragt der Abgleich höchstens 25 Issues nach.
**Der Abgleich hakt nur ab, er nimmt nie zurück.** Er sieht Zustände, keine Ereignisse:
ein offenes Issue kann auch bedeuten, dass das Schließen von Nebula aus fehlgeschlagen
ist. Ein symmetrischer Abgleich würde dann eine bewusst erledigte Aufgabe wieder
aufmachen. Das Zurücknehmen bleibt dem Webhook vorbehalten, der ein echtes „reopened“
sieht.
Ein gelöschtes Issue ist kein erledigtes — die Aufgabe bleibt stehen.
In beiden Fällen bewirkt ein geschlossenes Issue dasselbe wie ein Haken in der
Oberfläche: bei einer wiederkehrenden Aufgabe entsteht die nächste Fassung, und der
Vorgang landet im Projektverlauf (ohne Benutzer — das hat nicht jemand in Nebula getan,
sondern das Git-System gemeldet).
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
außen geht deshalb nur die Grenze um „erledigt“; ein Rundlauf verliert *In Arbeit* und
*Blockiert*. Labels wie `nebula:doing` wären die Alternative, würden aber den
Issue-Tracker mit Zuständen füllen, die dort niemand pflegt. Die feine Abstufung bleibt
in Nebula.
Eine echte beidseitige Synchronisation mit Konfliktauflösung gibt es **nicht** — das ist
der klassische Ort für stillen Datenverlust.
**Kein Widerspruch zu „keine eigenen Git-Operationen“:** Issues laufen über die HTTP-API
der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`, SSRF-Schutz
inklusive). Kein Klon, kein Arbeitsverzeichnis. Schreibende Aufrufe folgen dabei
**keinen Umleitungen** — ein 307 auf eine interne Adresse nähme den Rumpf und den Token
im Header mit.
### 4.7 Webhooks
Ein Push aktualisiert den Stand sofort, statt bis zum nächsten Durchlauf des Zeitgebers
zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Der Zeitplan bleibt als
Rückfalllösung bestehen.
Einzurichten am Repository (Funksymbol im Git-Bereich): Nebula erzeugt URL und
Geheimnis, eingetragen wird der Haken im Git-System von Hand. Über die API anzulegen
wäre machbar, verlangte aber Administrationsrechte am Repository — deutlich mehr, als
Nebula sonst braucht.
Das Geheimnis ist **genau einmal** zu sehen; danach liegt es verschlüsselt in der
Datenbank, wie die Zugangstoken, und verlässt den Server nicht mehr.
| System | Ausweis | Kopfzeile |
| --- | --- | --- |
| GitHub | HMAC-SHA256 über den Rumpf | `X-Hub-Signature-256: sha256=…` |
| Gitea | dieselbe Rechnung, ohne Präfix | `X-Gitea-Signature` (ältere: `X-Hub-Signature-256`) |
| GitLab | das Geheimnis im Klartext | `X-Gitlab-Token` |
GitLabs Verfahren ist schwächer: es beweist nur, dass der Absender das Geheimnis kennt,
nicht dass der Rumpf unverändert ist. Mehr gibt die Plattform nicht her; verglichen wird
trotzdem zeitkonstant.
Geprüft wird gegen den **rohen** Rumpf — durch `JSON.parse` und `JSON.stringify` gedreht
käme eine andere Zeichenfolge heraus und die Signatur passte nie.
Was der Endpunkt bewusst **nicht** tut: den Rumpf als Quelle für Inhalte benutzen. Titel,
Beschreibung und Zustand kommen weiter aus Nebula beziehungsweise beim nächsten Abgleich
aus der API. Wer das Geheimnis kennt, kann also höchstens eine Aufgabe auf- oder zumachen
und einen Abgleich auslösen — keine beliebigen Daten einschleusen.
Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler brächte das
Git-System zum Wiederholen und irgendwann zum Abschalten des Hakens.
Der Endpunkt ist der einzige ohne Anmeldung, der schreibt. Deshalb: Ratenbegrenzung je
Repository (60/Minute), Deckel auf die Rumpfgröße, und ein unbekanntes Repository
antwortet wie ein nicht eingerichtetes — mit 404, damit die ID nicht bestätigt wird.
---
## 4a. Export, Import und Sicherung
### Projekte mitnehmen
- **JSON-Export** einzeln oder für alle Projekte: Stammdaten, Notizen, Tags,
Repo-Verknüpfung. **Ohne Access Tokens** — die Verbindung wird nur beschrieben, das
Token muss beim Import neu hinterlegt werden. Auch ohne Snapshots (reproduzierbar) und
ohne Datenbank-IDs (gelten nur in der Quellinstanz).
- **Markdown-Export** eines Projekts: Beschreibung, Kennzahlen, Notizen und der letzte
bekannte Git-Stand als lesbare Datei.
- **Import mit Vorschau.** Die Datei wird streng geprüft, bevor irgendetwas passiert.
Abgeglichen wird über den Slug; bei Treffern stehen drei Strategien bereit:
| Strategie | Wirkung |
| --- | --- |
| Überspringen | Vorhandene bleiben unverändert |
| Zusätzlich anlegen | Kopie mit freiem Slug (`nebula` → `nebula-2`) |
| Ersetzen | Stammdaten überschreiben, Notizen austauschen |
Die Vorschau zeigt je Projekt, was geschähe, und weist auf fehlende Git-Verbindungen
hin. Erst ein zweiter Klick schreibt.
### Sicherung
- **JSON-Sicherung** im Adminbereich: Einstellungen, Konten, Verbindungen, Projekte und
Notizen in einer Datei. Passwörter als scrypt-Hash, Git-Tokens als Chiffrat — ohne
dasselbe `APP_SECRET` wertlos. Sitzungen und Passkeys bleiben draußen: erstere sind
kurzlebig, letztere hängen an Gerät und rpID.
- **Datenbank-Sicherung** über `scripts/backup.*`: `pg_dump` im Container, mit
`--clean --if-exists`, also für sich allein einspielbar. Gegenstück ist
`scripts/restore.*`, das vor dem Überschreiben nachfragt und sich über einen Parameter
auf eine Wegwerf-Datenbank umlenken lässt — damit sich eine Sicherung prüfen lässt,
ohne die echte anzufassen.
---
## 4b. Benachrichtigungen
Einzurichten unter *Mein Konto → Benachrichtigungen*, pro Konto und beliebig viele Ziele.
### Anlässe
| Anlass | Wann |
| --- | --- |
| ⚠️ **Abgleich fehlgeschlagen** | Das Git-System war nicht erreichbar oder hat abgelehnt |
| 🔴 **CI rot** | Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen |
| 🏷️ **Neues Release** | Ein neuer Tag oder ein neues Release ist aufgetaucht |
| 🕸️ **Projekt steht still** | Seit längerem kein Commit — Schwelle einstellbar, Vorgabe 60 Tage |
### Nur bei Änderung
Der entscheidende Punkt: Gemeldet wird **ausschließlich, wenn sich etwas ändert**
gegenüber dem, worüber zuletzt benachrichtigt wurde. Ein Repository mit abgelaufenem
Token meldet sich also **einmal**, nicht bei jedem stündlichen Durchlauf. Wird der Fehler
ein anderer, kommt eine neue Meldung; ist er behoben und tritt später erneut auf, ebenso.
Zwei Feinheiten:
- **Beim allerersten Abgleich** eines Repositories wird kein Release gemeldet — sonst
wäre jede bereits vorhandene Version eine „Neuigkeit".
- Der Merkstand wird auch dann fortgeschrieben, wenn **kein** Ziel eingerichtet ist. Sonst
käme beim ersten eingerichteten Ziel die ganze Vergangenheit hinterher.
### Stillstand
Der einzige Anlass, der an keinem Ereignis hängt — es passiert ja gerade nichts. Deshalb
ein eigener Durchlauf, **höchstens einmal am Tag**, unabhängig vom automatischen Abgleich.
Hört niemand auf diesen Anlass, läuft die Prüfung gar nicht erst.
Maßstab ist ausschließlich der **letzte Commit** im verknüpften Repository. Das
Änderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und würde den
Stillstand immer wieder zurücksetzen. Projekte **ohne Repository** bleiben deshalb außen
vor, ebenso fertige und archivierte.
Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit kam und es
seitdem wieder still geworden ist — ein Projekt, das aufwacht und wieder einschläft, meldet
sich also ein zweites Mal.
Die Schwelle setzt ein Administrator unter *Admin-Einstellungen → Stillstand* (14 Tage bis
ein Jahr). Eine Änderung wirkt sofort, nicht erst nach 24 Stunden.
### Zustellwege
| Weg | Details |
| --- | --- |
| **ntfy** | Vollständige Themen-URL. Titel, Priorität und Symbol als Kopfzeilen; optionales Bearer-Token für geschützte Themen. Läuft auch gegen ein selbst gehostetes ntfy im eigenen Netz. |
| **Webhook** | JSON per POST mit Anlass, Titel, Text, Projektbezug und Tieflink. Mit hinterlegtem Geheimnis wird der Rumpf per HMAC-SHA256 signiert (`X-Nebula-Signature`), damit der Empfänger die Herkunft prüfen kann. |
| **E-Mail** | Über SMTP. Steht erst zur Verfügung, wenn `SMTP_HOST` in der `.env` gesetzt ist — sonst ist die Auswahl in der Oberfläche gesperrt und erklärt, warum. |
Jedes Ziel lässt sich einzeln **testen** und **pausieren**; Ergebnis und Zeitpunkt der
letzten Zustellung stehen an der Karte.
### Absicherung
- Zieladressen sind Benutzereingaben, die der Server abruft — sie laufen deshalb durch
denselben **SSRF-Schutz** wie die Git-Verbindungen. Ein ntfy im eigenen Netz bleibt
erlaubt, Loopback und Cloud-Metadaten nicht.
- Redirects werden **nicht verfolgt**: ein Umleitungsziel wäre nicht geprüft.
- Geheimnisse liegen AES-256-GCM-verschlüsselt und verlassen den Server nie; die
Oberfläche zeigt nur einen Hinweis wie `test…mnis`.
- Eine fehlgeschlagene Zustellung lässt den Abgleich nicht scheitern — der Fehler landet
am Ziel, nicht im Sync.
---
## 5. Oberfläche
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
Raster, Neon-Akzente pro Projektfarbe.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
zehn Seitenzustände einschließlich der Dialoge und meldet keine Verstöße. Durchgehende
ARIA-Labels, Escape schließt Dialoge, sichtbarer Fokusring, `prefers-reduced-motion`
schaltet Animationen ab.
Bei der ersten Durchsicht fielen 26 Kontrastverstöße auf: gedämpfter Text erreichte auf
dem sehr dunklen Untergrund nur 3,8 statt 4,5 zu 1. Statt an hundert Stellen die Klasse
zu tauschen, sind die beiden gedämpften Stufen im Theme aufgehellt — der Abstand
zwischen ihnen bleibt sichtbar.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
oder als Tropfen zuschneidet.
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
beiden Angaben hier schon einmal auseinandergelaufen sind.
---
## 6. Daten und Technik
### 6.1 Datenmodell
| Tabelle | Inhalt |
| --- | --- |
| `User` | Konten, Rolle, Passwort-Hash, Sperrzähler |
| `Session` | Serverseitige Sitzungen (nur der Token-Hash) |
| `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler |
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
| `RecoveryCode` | Wiederherstellungscodes für die Zwei-Faktor-Anmeldung (nur der Hash) |
| `PendingLogin` | Zwischenzustand zwischen Passwort und zweitem Faktor, fünf Minuten gültig |
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels, Wiederholung und Issue-Herkunft |
| `ProjectTemplate` | Vorlagen je Konto: Stammdaten, Tags, Notizen und Aufgaben |
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Repository) |
`Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus
der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos
übernommen. Slug und Verbindungsname sind je Besitzer eindeutig — zwei Personen dürfen
beide ein Projekt „dashboard" haben.
Listen wie Branches oder Commits liegen als `JSONB`, damit providerspezifische Felder
nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
### 6.2 Sicherheit
- **Zugangsschutz in zwei Ebenen.** Die Middleware prüft nur, ob ein Sitzungscookie
überhaupt mitkommt — im Edge-Runtime gibt es keine Datenbank. Verbindlich entschieden
wird serverseitig in jeder Seite und jedem Endpunkt (`requireUserPage` /
`requireUserApi`). Das ist die eigentliche Sicherheitsgrenze.
- **Datentrennung** über eine Besitzerbedingung in jeder Abfrage. Schreibzugriffe laufen
über `updateMany`/`deleteMany` mit `ownerId` in der `where`-Klausel, treffen fremde
Datensätze also gar nicht erst. Fremde IDs liefern `404` statt `403`.
- **CSRF**: Origin- bzw. `Sec-Fetch-Site`-Prüfung für alle schreibenden Methoden, dazu
erzwungener `Content-Type: application/json`.
- **CSP mit Per-Request-Nonce** und `strict-dynamic`, plus `frame-ancestors 'none'`,
`object-src 'none'`, `base-uri 'none'` und die üblichen Schutz-Header.
- **SSRF-Schutz** vor jedem ausgehenden Git-Aufruf: Hostname wird aufgelöst, jede
Antwortadresse geprüft (schützt auch gegen DNS-Rebinding über einen zweiten A-Record),
Redirect-Ziele werden erneut geprüft. Konfigurierbar über `GIT_ALLOW_LOOPBACK`,
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
WebAuthn-Optionen.
- **Sitzungen**: Zufallstoken mit 256 Bit, in der Datenbank nur als SHA-256-Hash — wer die
Datenbank liest, kann keine Sitzung übernehmen. Geprüft wird bei jeder Anfrage, Abmelden
und Sperren wirken also sofort. Zwei Fristen nach ASVS 3.3.2: **absolut** 30 Tage ab
Anmeldung (`SESSION_TTL_DAYS`) und **im Leerlauf** 8 Stunden (`SESSION_IDLE_HOURS`).
Ein Passwortwechsel entwertet alle Sitzungen und stellt für das eigene Gerät eine neue
aus.
- **Cookies**: `HttpOnly`, `SameSite=Strict`, `Secure` sobald die App über HTTPS läuft, und
dann zusätzlich mit dem Präfix `__Host-`. Der Präfix lässt den Browser selbst erzwingen,
dass kein `Domain` gesetzt ist — eine andere Subdomain kann den Cookie damit nicht
überschreiben und keine Sitzung unterschieben.
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
ohne `tokenCipher`.
- **Zod-Validierung** jeder Eingabe an der API-Grenze mit Feldfehlern für die UI, Body auf
256 KB begrenzt.
- Prisma arbeitet ausschließlich mit parametrisierten Abfragen.
### 6.3 Betrieb
- **Zwei gleichwertige Betriebsarten**: Standalone (Node-Prozess, beliebige PostgreSQL)
oder komplett in Docker — dieselbe Codebasis, dieselbe `.env`.
- **Docker-Compose-Profile**: `db` läuft immer, `--profile full` startet zusätzlich die
App, `--profile tools` bringt Adminer als DB-Oberfläche mit.
- **Automatische Migration** beim Containerstart, inklusive Warteschleife auf die
Datenbank.
- **Healthchecks**: `pg_isready` für Postgres, `/api/health` für die App.
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
### 6.4 Tests
Zwei Ebenen. `npm run test:all` führt beide aus.
**`npm test` — Unit-Tests** (Vitest, 56 Stück, unter einer Sekunde, ohne DOM und ohne
Datenbank):
| Bereich | Was geprüft wird |
| --- | --- |
| `utils` | Slug-Erzeugung inklusive Umlauten und Längenbegrenzung, Tag-Aufbereitung, Kürzen, Zahlenformat |
| `git/parse` | URL-Zerlegung für https, SSH, verschachtelte GitLab-Gruppen und Oberflächenpfade; Provider-Erkennung; Bildung der Aktivitätskurve |
| `security/ssrf` | Sperrlisten für Loopback, Link-Local, Metadaten-Endpunkt, CGNAT und Multicast; Formprüfung; die drei Umgebungsschalter |
| `auth/password` | scrypt-Rundlauf, unterschiedliches Salz, Ablageformat, Verhalten bei kaputten Hashes, Passwortregeln |
| `tasks` | Berechnung des abgeleiteten Fortschritts, Spaltenreihenfolge |
| `notify/pending` | Wann eine Benachrichtigung fällig ist: neuer Fehler ja, gleicher Fehler nein; CI-Wechsel nach rot; Release erst ab dem zweiten Abgleich |
| `notify/stale` | Stillstandsschwelle, keine Zweitmeldung, aber erneute Meldung nach zwischenzeitlicher Aktivität |
| `validation` | Dass jeder Anlass, den die Oberfläche anbietet, auch vom Schema akzeptiert wird — entstanden aus genau diesem Fehler |
**`npm run test:e2e` — Ende-zu-Ende** (Playwright, 13 Stück, rund 20 Sekunden). Läuft
gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 — also durch Middleware,
Authentifizierung, Validierung und Datenbank hindurch:
| Datei | Was geprüft wird |
| --- | --- |
| `e2e/api.spec.ts` | Einrichtung sperrt sich nach dem ersten Konto · kein Zugriff ohne Anmeldung · fremdes Origin und falscher Content-Type abgewiesen · SSRF geblockt · **Datentrennung zwischen zwei Konten** · Aufgaben-Reihenfolge, abgeleiteter Fortschritt und untergeschobene IDs · Export ohne Geheimnisse, Import je Strategie |
| `e2e/ui.spec.ts` | Anmelden · Projekt anlegen · Aufgaben anlegen und **per Ziehen verschieben** (inklusive Prüfung nach dem Neuladen) · Volltextsuche · Abmelden sperrt geschützte Seiten wieder |
Zwei Dinge sind bewusst so gebaut:
- **Die Testdatenbank kann nicht die echte sein.** `e2e/global-setup.ts` bricht ab, wenn
der Datenbankname nicht `test` enthält — vor dem `TRUNCATE`. Der teuerste denkbare
Fehler wäre eine Testreihe, die die Produktivdaten leert.
- **Playwright startet den Server selbst** (`webServer` in `playwright.config.ts`) mit
eigener `DATABASE_URL` und eigenem Port. Eine laufende Instanz auf 3000 stört nicht.
Einmalig vorbereiten:
```bash
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test"
```
### 6.5 Fortlaufende Prüfung
`.gitea/workflows/ci.yml` prüft jeden Push und jeden Pull Request: Typen, Unit-Tests,
Bau und Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlägt etwas fehl,
wird der Playwright-Bericht als Artefakt abgelegt.
Dafür müssen in der Gitea-Instanz Actions aktiviert sein (`app.ini`: `[actions] ENABLED
= true`) und ein Runner registriert. Die Syntax ist GitHub-kompatibel — für GitHub die
Datei nach `.github/workflows/` kopieren.
### 6.6 API
Zehn REST-Endpunkte für Projekte, Notizen, Verbindungen, Sync, Repo-Listen und Health.
Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
[README](README.md#api).
---
## 7. Ideen für später
Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
+453
View File
@@ -0,0 +1,453 @@
<div align="center">
# ✦ Nebula
**Das Kontrollzentrum für deine Vibe-Coding Projekte.**
Ideen festhalten · Status pflegen · Notizen sammeln · Git-Repos live im Blick
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
</div>
---
## Was ist Nebula?
Vibe Coding produziert schnell viele halbfertige Projekte: ein Repo hier, eine Idee dort,
irgendwo eine Notiz in einer Textdatei. Nebula bündelt das an einem Ort.
Jedes Projekt bekommt einen **Status** (von der rohen Idee bis „Fertig"), beliebig viele
**Notizen** und optional ein **verknüpftes Repository**. Nebula holt sich von dort per API
Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab**
oder eine selbst gehostete **Gitea**-Instanz.
Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula
kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in
dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in
den Admin-Einstellungen.
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
Access Tokens werden verschlüsselt abgelegt.
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. Für den Dauerbetrieb auf einem
Server — TLS, Firewall, Sicherungen und Updates — gibt es **[DEPLOYMENT.md](DEPLOYMENT.md)**.
---
## Schnellstart
Zwei Betriebsarten, dieselbe Codebasis. Beide brauchen zuerst eine `.env`:
```bash
node scripts/setup.mjs # legt .env an und erzeugt ein zufälliges APP_SECRET
```
> Ohne Node zur Hand? Einfach `.env.example` nach `.env` kopieren und `APP_SECRET` von Hand
> auf einen langen Zufallswert setzen.
### Variante A — Standalone (empfohlen zum Entwickeln)
Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container:
```bash
npm run docker:db # Postgres 17 auf 127.0.0.1:5432
npm install
npm run db:migrate # Schema anlegen
npm run db:seed # optional: drei Beispielprojekte
npm run dev # → http://localhost:3000
```
Für den Produktivbetrieb ohne Docker:
```bash
npm run build
npm start # → http://localhost:3000
```
### Variante B — Alles in Docker
```bash
docker compose --profile full up -d --build # → http://localhost:3000
```
Der App-Container spielt beim Start automatisch die Migrationen ein und wartet dabei auf
die Datenbank. Logs mitlesen:
```bash
docker compose --profile full logs -f app
```
Stoppen:
```bash
docker compose --profile full down # Container weg, Daten bleiben
docker compose --profile full down -v # inkl. Datenbank-Volume
```
### Variante C — Eigene, bereits laufende PostgreSQL
Docker wird gar nicht gebraucht. In der `.env` nur den Connection-String setzen:
```env
DATABASE_URL="postgresql://benutzer:passwort@dbhost:5432/nebula?schema=public"
```
Dann `npm install && npm run db:migrate && npm run dev`.
---
## Voraussetzungen
| Variante | Benötigt |
| --- | --- |
| Standalone | Node.js ≥ 20.11, npm, PostgreSQL ≥ 14 (lokal oder via Docker) |
| Docker | Docker Engine ≥ 24 mit Compose v2 |
---
## Konfiguration
Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| Variable | Bedeutung | Default |
| --- | --- | --- |
| `APP_NAME` | Anzeigename: Seitentitel, Kopfzeile, Passkey-Dialog | `Nebula` |
| `APP_TAGLINE` | Kleine Zeile unter dem Namen | `Vibe Coding` |
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
| `SESSION_TTL_DAYS` | Absolute Lebensdauer einer Anmeldung in Tagen | `30` |
| `SESSION_IDLE_HOURS` | Nach so vielen Stunden ohne Nutzung läuft die Sitzung ab (`0` = aus) | `8` |
| `PORT` | Port der Anwendung | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
| `NOTIFY_TIMEOUT_MS` | Timeout beim Zustellen von Benachrichtigungen | `10000` |
| `SMTP_HOST` … `SMTP_FROM` | SMTP-Zugang; ohne `SMTP_HOST` steht der Weg „E-Mail" nicht zur Verfügung | leer |
`APP_NAME`, `APP_TAGLINE` und `APP_URL` werden **zur Laufzeit** gelesen. Dasselbe
Docker-Image lässt sich also ohne Neubau umbenennen oder unter einer anderen Adresse
betreiben — Container neu starten genügt.
> **`APP_SECRET` nachträglich ändern** macht bereits gespeicherte Git-Tokens unlesbar.
> Sie müssen dann in den Verbindungen neu hinterlegt werden — Konten, Passwörter und
> Passkeys bleiben unberührt.
> **`APP_URL` nachträglich ändern** (anderer Hostname) macht bestehende **Passkeys**
> ungültig, weil sie an die RP-ID gebunden sind. Passwörter funktionieren weiter.
---
## Erste Anmeldung
Beim ersten Aufruf landest du auf `/setup`. Dort legst du das Administratorkonto an und
wählst die Betriebsart:
- **Einzelbetrieb** — genau ein Konto, keine Registrierung, keine Benutzerliste.
- **Mehrbenutzerbetrieb** — beliebig viele Konten. Projekte, Notizen und Git-Verbindungen
sind pro Konto strikt getrennt; auch ein Administrator sieht fremde Projekte nicht.
Nach der Einrichtung ist `/setup` gesperrt. Bereits vorhandene Projekte ohne Besitzer
(etwa aus den Seed-Daten) werden dem neuen Konto zugeordnet.
**Passkey einrichten:** unter *Mein Konto → Passkeys → Passkey hinzufügen*. Danach genügt
auf der Anmeldeseite ein Klick auf „Mit Passkey anmelden" — ohne Benutzernamen, weil
Nebula auffindbare Credentials nutzt.
> Passkeys brauchen einen sicheren Kontext. Sie funktionieren über `https://` und über
> `http://localhost`, **nicht** über eine LAN-IP ohne TLS. `APP_URL` muss dabei exakt der
> Adresse entsprechen, unter der du die App aufrufst.
## Git-Verbindung einrichten
1. Im Git-System einen **Personal Access Token** mit Leserechten erzeugen:
| System | Scopes |
| --- | --- |
| GitHub | Fine-grained Token: *Contents: Read*, *Metadata: Read*, *Issues: Read*, *Pull requests: Read* |
| GitLab | `read_api` (alternativ `read_repository`) |
| Gitea / Forgejo | `repository:read`, `issue:read`, `user:read` |
2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen:
System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token.
3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück.
4. Beim Anlegen oder Bearbeiten eines Projekts die Verbindung auswählen und über
**Repos laden** das Repository aussuchen (oder die URL direkt eintippen).
5. Auf der Projektseite **Jetzt synchronisieren** — Branches, Tags, Commits und Releases
werden geholt und als Snapshot gespeichert.
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
### Automatisch statt auf Knopfdruck
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
jeder Durchlauf API-Kontingent verbraucht.
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
Projektdialog ausnehmen; manuell synchronisieren geht immer.
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
`POST /api/admin/sync/run` per eigenem Cron auf.
---
## Export, Import und Sicherung
### Projekte mitnehmen
| Weg | Wo | Inhalt |
| --- | --- | --- |
| **Alle Projekte als JSON** | Dashboard → Datenbank-Symbol | Stammdaten, Notizen, Repo-Verknüpfung |
| **Ein Projekt als JSON** | Projektseite → Download-Symbol | dasselbe, für ein Projekt |
| **Ein Projekt als Markdown** | Projektseite → Download-Symbol | lesbar, inklusive letztem Git-Stand |
| **Import** | Dashboard → Datenbank-Symbol | JSON einlesen, mit Vorschau |
**Access Tokens sind nie enthalten.** Die Git-Verbindung wird nur beschrieben (Name,
System, Basis-URL); nach dem Import muss das Token neu hinterlegt werden. Verweist eine
importierte Datei auf eine hier unbekannte Verbindung, wird das Projekt ohne Verknüpfung
angelegt und in der Vorschau angezeigt.
Beim Import wird über den Slug abgeglichen. Für Treffer gibt es drei Strategien:
- **Überspringen** — Vorhandene bleiben unverändert
- **Zusätzlich anlegen** — Kopie mit freiem Slug (`nebula` → `nebula-2`)
- **Ersetzen** — Stammdaten überschreiben, Notizen austauschen
Vor dem Schreiben zeigt eine Vorschau, was passieren würde. Erst dann wird geschrieben.
### Sicherung
Zwei Wege, mit unterschiedlichem Zweck:
**JSON-Sicherung** (Admin-Einstellungen → Sicherung) enthält Einstellungen, Konten,
Verbindungen, Projekte und Notizen. Gut zum Umziehen. Passwörter liegen als scrypt-Hash
vor, Git-Tokens als Chiffrat — ohne dasselbe `APP_SECRET` sind sie wertlos. Trotzdem
vertraulich behandeln.
**Datenbank-Sicherung** über `pg_dump` — das, was man zum Wiederherstellen einer kaputten
Instanz braucht:
```powershell
.\scripts\backup.ps1 # → .\backups\nebula-<datum>.sql
.\scripts\restore.ps1 -File .\backups\nebula-....sql
```
```bash
./scripts/backup.sh # → ./backups/nebula-<datum>.sql
./scripts/restore.sh backups/nebula-....sql
```
Die Skripte lesen Benutzer und Datenbankname aus der `.env` und führen `pg_dump` im
Container aus — auf dem Rechner selbst muss nichts installiert sein.
> Eine Sicherung ist erst dann eine Sicherung, wenn du sie einmal zurückgespielt hast.
> Das geht ohne Risiko in eine Wegwerf-Datenbank:
>
> ```powershell
> docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
> .\scripts\restore.ps1 -File .\backups\nebula-....sql -Database nebula_test -Force
> ```
## Projektstruktur
```
nebula/
├── docker/entrypoint.sh Migrationen + Serverstart im Container
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
├── prisma/
│ ├── schema.prisma Datenmodell
│ ├── migrations/ versionierte SQL-Migrationen
│ └── seed.ts Beispieldaten
├── scripts/setup.mjs .env erzeugen inkl. APP_SECRET
└── src/
├── app/
│ ├── page.tsx Dashboard (Board, Filter, Kennzahlen)
│ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Git
│ ├── connections/ Verwaltung der Git-Verbindungen
│ └── api/ REST-Endpunkte (Projekte, Notizen, Verbindungen, Sync)
├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge)
├── lib/
│ ├── crypto.ts AES-256-GCM für Access Tokens
│ ├── db.ts Prisma-Client
│ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts
│ ├── sync.ts Repo-Snapshot holen und speichern
│ └── validation.ts Zod-Schemas für alle Eingaben
└── types/ gemeinsame Typen für Server und Client
```
---
## npm-Skripte
| Skript | Zweck |
| --- | --- |
| `npm run dev` | Entwicklungsserver mit Hot Reload |
| `npm run build` / `npm start` | Produktionsbuild und -start (ohne Docker) |
| `npm run setup` | `.env` anlegen inkl. generiertem `APP_SECRET` |
| `npm run db:migrate` | Migrationen einspielen (`prisma migrate deploy`) |
| `npm run db:migrate:dev` | Neue Migration aus Schemaänderungen erzeugen |
| `npm run db:seed` | Beispielprojekte anlegen |
| `npm run db:studio` | Prisma Studio öffnen |
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) |
| `npm test` | Unit-Tests (Vitest), unter einer Sekunde |
| `npm run test:watch` | Tests im Beobachtungsmodus |
| `npm run typecheck` | TypeScript prüfen |
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
| `npm run test:all` | Beides nacheinander |
| `npm run docker:db` | Nur die Datenbank starten, mit Host-Port für `npm run dev` |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` |
---
## API
Alle Endpunkte liefern und erwarten JSON.
Bis auf `/api/health` und `/api/auth/*` erfordern alle Endpunkte eine Anmeldung und liefern
ausschließlich Daten des angemeldeten Kontos.
| Methode | Pfad | Zweck |
| --- | --- | --- |
| `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) |
| `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) |
| `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung |
| `POST` | `/api/auth/mfa` | Zweiter Faktor: Einmalkennwort oder Wiederherstellungscode |
| `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand |
| `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn |
| `PATCH` | `/api/account/profile` | Eigenes Profil |
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
| `GET` `POST` `PUT` `DELETE` | `/api/account/totp` | Zwei-Faktor: Zustand, einrichten, aktivieren, abschalten |
| `POST` | `/api/account/totp/recovery` | Neue Wiederherstellungscodes |
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` `POST` | `/api/templates` | Projektvorlagen auflisten / anlegen |
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
| `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
| `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen |
| `POST` | `/api/notifications/:id/test` | Testnachricht senden |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen |
| `PATCH` | `/api/projects/reorder` | Neue Reihenfolge im Brett |
| `POST` | `/api/projects/bulk` | Status, Tags oder Löschen für mehrere |
| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) |
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
| `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen |
| `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen |
| `PATCH` `DELETE` | `/api/connections/:id` | Verbindung ändern / löschen |
| `POST` | `/api/connections/:id/check` | Verbindung und Token testen |
| `GET` | `/api/connections/:id/repos` | Zugängliche Repositories auflisten |
Beispiel — Idee per Kommandozeile anlegen:
```bash
curl -X POST http://localhost:3000/api/projects \
-H "Content-Type: application/json" \
-d '{"name":"Neue Idee","status":"IDEA","tags":["experiment"]}'
```
---
## Sicherheit
| Bereich | Umsetzung |
| --- | --- |
| **Anmeldung** | Passwort-Hashing mit **scrypt** (N=32768, r=8, p=1), serverseitige Sitzungen — in der Datenbank liegt nur der SHA-256-Hash des Cookie-Tokens |
| **Passkeys** | WebAuthn über SimpleWebAuthn, Challenges einmalig verwendbar und in der DB abgelegt, Signaturzähler wird fortgeschrieben |
| **Cookies** | `HttpOnly`, `SameSite=Lax`, `Secure` automatisch bei https |
| **CSRF** | Jede schreibende Anfrage muss vom eigenen Origin kommen (`Sec-Fetch-Site` / `Origin`); zusätzlich wird `Content-Type: application/json` erzwungen, was den Formular-Trick ohne Preflight ausschließt |
| **CSP** | Strikte Policy mit Per-Request-**Nonce** und `strict-dynamic`, dazu `frame-ancestors 'none'`, `object-src 'none'`, `base-uri 'none'` |
| **Weitere Header** | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy` |
| **SSRF** | Die Basis-URL einer Git-Verbindung ist Benutzereingabe. Vor jedem ausgehenden Aufruf werden Hostname und **alle** aufgelösten IPs geprüft (auch nach Redirects); Loopback, Link-Local samt Cloud-Metadaten, Multicast und CGNAT sind gesperrt |
| **Datentrennung** | Jede Abfrage filtert auf den Besitzer. Fremde IDs liefern `404`, nicht `403` — die Antwort verrät also nicht einmal, dass es den Datensatz gibt |
| **Rate-Limiting** | Anmeldung pro IP und Konto, ausgehende Git-Aufrufe, WebAuthn-Optionen; Konto sperrt sich nach 8 Fehlversuchen für 15 Minuten |
| **Eingaben** | Zod-Schemas an jeder API-Grenze, Body auf 256 KB begrenzt |
| **Git-Tokens** | AES-256-GCM, Schlüssel per scrypt aus `APP_SECRET`. Werden nie an den Browser ausgeliefert — die API zeigt nur `ghp_…9f3a` |
| **Sitzungsende** | Deaktivieren, Löschen oder ein neues Passwort beenden sofort alle Sitzungen des Kontos |
Grenzen, die du kennen solltest:
- Ist `APP_SECRET` nicht gesetzt, warnt die App beim Start und nutzt einen unsicheren
Entwicklungsschlüssel.
- Ohne `Origin`- **und** `Sec-Fetch-Site`-Header wird eine schreibende Anfrage
durchgelassen. Das betrifft nur Nicht-Browser-Clients (curl, Skripte) und ist bewusst so,
damit die API skriptbar bleibt — ein CSRF-Vektor ist es nicht, weil jeder Browser
mindestens einen der beiden Header sendet.
- Das Rate-Limiting liegt im Arbeitsspeicher und wird beim Neustart zurückgesetzt.
- Es gibt **kein** HSTS und keine TLS-Terminierung. Für den Betrieb im Internet gehört ein
Reverse Proxy mit gültigem Zertifikat davor — der ist ohnehin nötig, damit Passkeys
funktionieren.
- Passwort-Zurücksetzen per E-Mail gibt es nicht; ein Administrator setzt Passwörter neu.
---
## Fehlersuche
| Symptom | Ursache / Lösung |
| --- | --- |
| `Can't reach database server` | Läuft `npm run docker:db`? Ein blosses `docker compose up -d db` startet die DB **ohne Host-Port** — dann ist sie von `npm run dev` aus nicht erreichbar. Stimmt `DATABASE_URL`? |
| `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` |
| Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen |
| Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" |
| Sync meldet `403 Rate-Limit` | Ohne Token ist das Limit sehr niedrig — Token hinterlegen |
| Tokens plötzlich ungültig | `APP_SECRET` wurde geändert — Tokens neu eintragen |
| Port 3000 belegt | `APP_PORT` in der `.env` ändern (Docker) bzw. `npm run dev -- -p 3001` |
| `package.json#prisma is deprecated` | Harmlose Warnung von Prisma 6.19. Die Umstellung auf `prisma.config.ts` steht aus, weil dann die `.env` nicht mehr automatisch geladen wird — vor Prisma 7 zu erledigen |
| Passkey-Knopf fehlt / „kein sicherer Kontext" | `APP_URL` zeigt weder auf `https://` noch auf `http://localhost` |
| Passkey wird nicht akzeptiert | `APP_URL` weicht von der aufgerufenen Adresse ab, oder der Hostname wurde geändert (RP-ID) |
| Nach `docker compose down -v` ist alles weg | Das Volume enthielt Konten und Projekte — `-v` löscht es |
| Kein Zugriff auf die eigene Gitea im LAN | Läuft sie auf demselben Rechner? Dann `GIT_ALLOW_LOOPBACK=true` setzen |
---
## Getestet mit
Stand 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei
Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz).
| | Version |
| --- | --- |
| Node.js | 26.5.0 |
| npm | 11.17.0 |
| Next.js | 15.5.21 |
| Prisma | 6.19.3 |
| PostgreSQL | 17 (Alpine) |
| Docker / Compose | 29.6.2 / v5.3.1 |
---
## Lizenz
MIT — siehe [LICENSE](LICENSE).
+145
View File
@@ -0,0 +1,145 @@
# Roadmap
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
Was bereits steht, listet [FEATURES.md](FEATURES.md).
---
## 1 · Als Nächstes empfohlen
### 1.0 Rest aus v1.6.0: persönliche API-Token `M`
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per
Shortcut.
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
Migration wert.
---
### 1.2 Läuft es noch? `M`
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen.
### 1.3 Persönliche API-Token `M`
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den
Sitzungen.
---
### 1.4 Aufgaben ↔ Issues: was noch fehlt `S`
Mit v1.7.0 stehen beide Richtungen: „als Issue anlegen“ und der Rückweg über
Webhooks. Offen geblieben ist Kleinkram:
- **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem
Projekt aus Frontend und Backend fehlt die Wahl.
- **Beim Übernehmen aus Issues** wird der Rückweg nicht automatisch
eingerichtet — der Webhook ist getrennt zu setzen.
Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige
Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht
aufeinander — ein Issue kennt nur offen und geschlossen, `DOING` und
`BLOCKED` gehen im Rundlauf verloren. Das ist hingenommen, statt den
Issue-Tracker mit Labels zu füllen, die dort niemand pflegt.
---
## 2 · Größere Brocken
### 2.1 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 2.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst.
---
## 3 · Kleinigkeiten
| Idee | Aufwand | Nutzen |
| --- | --- | --- |
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.4 ab |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
---
## 4 · Bewusst nicht
Damit klar ist, was **nicht** kommt und warum:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
- **Veraltete Abhängigkeiten prüfen.** Klingt verlockend, aber Renovate und
Dependabot machen das besser, und es wären viele Aufrufe an fremde
Registries — für eine Zahl, die im Repo selbst schon steht.
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
bei jeder Sicherung. Ein Link ins Git tut es auch.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
der Punkt.
---
## Erledigt
| | |
| --- | --- |
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
| GitHub, GitLab und Gitea | ✓ |
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
| Einzel- und Mehrbenutzerbetrieb | ✓ |
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
| Docker- und Standalone-Betrieb | ✓ |
| Automatischer Abgleich mit Zeitplan | ✓ |
| Export, Import und Sicherung | ✓ |
| **v0.3.0** Markdown, Volltextsuche über Notizen, PR- und Issue-Listen, Testfundament | ✓ |
| **v0.4.0** Kanban mit Drag & Drop, CI-Status je Branch, Commit-Aktivität | ✓ |
| **v0.5.0** Aufgabenbrett je Projekt mit sortierbaren Spalten, Fortschritt aus Aufgaben | ✓ |
| **v0.6.0** Ende-zu-Ende-Tests mit Playwright, CI-Pipeline für Gitea Actions | ✓ |
| **v0.7.0** Benachrichtigungen über ntfy, Webhook und E-Mail | ✓ |
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
| **v1.7.0** Aufgabe als Git-Issue anlegen, Webhooks für Push und Issues | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
ebenfalls draußen — sie sind reproduzierbar. Der Projekt-Export steht jedem Konto für
seine eigenen Daten offen, die Voll-Sicherung nur Administratoren.
+23
View File
@@ -0,0 +1,23 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer die Entwicklung: macht die Datenbank auf dem Host erreichbar.
#
# Gebraucht wird das nur, wenn die Anwendung ausserhalb des Containers
# laeuft – "npm run dev" verbindet sich dann ueber localhost:5432. Im
# Vollbetrieb spricht die Anwendung "db:5432" im Docker-Netz an und braucht
# diese Datei nicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev
#
# Kuerzer: npm run docker:db
#
# Gebunden wird auf 127.0.0.1, nicht auf 0.0.0.0. Docker traegt Freigaben
# direkt in iptables ein und umgeht dabei ufw – eine offene Bindung stuende
# also auch bei aktiver Firewall im Netz.
# ─────────────────────────────────────────────────────────────
services:
db:
ports:
- "${POSTGRES_PORT:-127.0.0.1:5432}:5432"
+46
View File
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer den Serverbetrieb: kein einziger Port geht zum Host.
#
# Gedacht fuer einen Docker-Host, auf dem mehrere Stacks liegen und ein
# gemeinsamer Reverse Proxy die einzige Tuer nach draussen ist. Die
# Datenbank veroeffentlicht ohnehin nichts; diese Datei nimmt zusaetzlich
# die Freigabe der Anwendung zurueck und haengt sie in ein geteiltes Netz,
# ueber das der Proxy sie erreicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.server.yml \
# --profile full up -d --build
#
# Bequemer ist, das dauerhaft in die .env zu schreiben:
# COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
# COMPOSE_PROFILES=full
# Danach genuegt ueberall "docker compose up -d" – auch in update.sh.
# (Trennzeichen: Doppelpunkt unter Linux, Semikolon unter Windows.)
#
# Das Netz muss es vorher geben (der Proxy-Stack legt es ueblicherweise an):
# docker network create proxy
#
# Im Proxy zeigt das Ziel dann auf http://nebula:3000 bzw. nebula-app:3000.
# ─────────────────────────────────────────────────────────────
services:
app:
# !reset loescht die Portfreigabe aus der Basisdatei. Ohne dieses Tag
# wuerden die Listen zusammengefuegt statt ersetzt – Compose behandelt
# "ports" beim Zusammenfuehren als anzuhaengende Liste. Braucht
# Compose 2.24 oder neuer.
ports: !reset []
networks:
# Das stackeigene Netz: darueber spricht die Anwendung mit der Datenbank.
default:
# Das geteilte Netz zum Reverse Proxy. Der Alias ist der Name, unter dem
# der Proxy Nebula anspricht – kurz und eindeutig, denn "app" hiessen in
# einem geteilten Netz schnell mehrere Container.
proxy:
aliases:
- ${PROXY_ALIAS:-nebula}
networks:
proxy:
external: true
name: ${PROXY_NETWORK:-proxy}
+106
View File
@@ -0,0 +1,106 @@
name: nebula
# ─────────────────────────────────────────────────────────────
# Diese Datei veroeffentlicht nur einen einzigen Port, und den auf dem
# Loopback: den der Anwendung. Die Datenbank bleibt im Docker-Netz.
#
# 1) Alles im Container (Server wie Entwicklung):
# docker compose --profile full up -d --build
#
# 2) Standalone-Entwicklung – Datenbank im Container, App auf dem Host.
# Dafuer muss Postgres auf dem Host erreichbar sein, also mit dem
# Entwicklungs-Overlay (oder kurz: npm run docker:db):
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev
#
# 3) Server ohne jede Portfreigabe, hinter einem Reverse Proxy im
# gemeinsamen Docker-Netz: siehe docker-compose.server.yml
#
# Optionales DB-Webinterface:
# docker compose --profile tools up -d adminer → http://localhost:8080
# ─────────────────────────────────────────────────────────────
services:
db:
image: postgres:17-alpine
container_name: nebula-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-nebula}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nebula}
POSTGRES_DB: ${POSTGRES_DB:-nebula}
# Deterministische Sortierung unabhaengig vom Host-Locale
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
# Absichtlich keine Portfreigabe: die Anwendung erreicht die Datenbank im
# Container ueber "db:5432", und backup.sh geht ueber "docker exec". Nach
# aussen muss hier nichts. Wer die Datenbank vom Host aus braucht – fuer
# "npm run dev" oder psql – nimmt docker-compose.dev.yml dazu.
volumes:
- nebula-db-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nebula} -d ${POSTGRES_DB:-nebula}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
app:
profiles: ["full"]
build:
context: .
dockerfile: Dockerfile
image: nebula-app:latest
container_name: nebula-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-nebula}:${POSTGRES_PASSWORD:-nebula}@db:5432/${POSTGRES_DB:-nebula}?schema=public
# Unbedingt in der .env ueberschreiben – verschluesselt die Git-Tokens.
APP_SECRET: ${APP_SECRET:-nebula-dev-secret-bitte-in-der-env-aendern}
# Name und oeffentliche Adresse. APP_URL bestimmt die WebAuthn-rpID –
# passt sie nicht zur aufgerufenen Adresse, scheitern Passkeys.
APP_NAME: ${APP_NAME:-Nebula}
APP_TAGLINE: ${APP_TAGLINE:-Vibe Coding}
APP_URL: ${APP_URL:-http://localhost:3000}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000}
NOTIFY_TIMEOUT_MS: ${NOTIFY_TIMEOUT_MS:-10000}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
GIT_ALLOW_LOOPBACK: ${GIT_ALLOW_LOOPBACK:-false}
GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false}
GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-}
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
ports:
# Nur auf dem Loopback: nach draussen kommt man ueber den Reverse Proxy.
# Docker schreibt seine Freigaben an ufw vorbei direkt in iptables – eine
# Bindung auf 0.0.0.0 stuende also im Netz, auch bei aktiver Firewall.
# Ganz ohne Host-Port: docker-compose.server.yml.
- "${APP_PORT:-127.0.0.1:3000}:3000"
adminer:
profiles: ["tools"]
image: adminer:latest
container_name: nebula-adminer
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula
ports:
- "${ADMINER_PORT:-127.0.0.1:8080}:8080"
volumes:
nebula-db-data:
name: nebula-db-data
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Startet Nebula im Container: erst Migrationen, dann der Server.
# ─────────────────────────────────────────────────────────────
set -e
PRISMA_CLI="/opt/prisma-cli/node_modules/prisma/build/index.js"
SCHEMA="/app/prisma/schema.prisma"
if [ -z "$DATABASE_URL" ]; then
echo "[nebula] FEHLER: DATABASE_URL ist nicht gesetzt." >&2
exit 1
fi
if [ ! -f "$PRISMA_CLI" ]; then
echo "[nebula] FEHLER: Prisma-CLI nicht unter $PRISMA_CLI gefunden." >&2
exit 1
fi
echo "[nebula] Migrationen werden eingespielt…"
attempt=0
max_attempts=15
until node "$PRISMA_CLI" migrate deploy --schema "$SCHEMA"; do
attempt=$((attempt + 1))
if [ "$attempt" -ge "$max_attempts" ]; then
echo "[nebula] FEHLER: Migration nach $max_attempts Versuchen fehlgeschlagen." >&2
exit 1
fi
echo "[nebula] Datenbank noch nicht bereit – neuer Versuch in 3s ($attempt/$max_attempts)"
sleep 3
done
echo "[nebula] Migrationen ok. Server startet auf Port ${PORT:-3000}."
exec "$@"
+964
View File
@@ -0,0 +1,964 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
/**
* Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in
* dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet.
*/
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
/**
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
* Authentifizierung, Validierung und Datenbank hindurch. Genau die Dinge,
* die Unit-Tests nicht erreichen.
*
* Die Tests bauen aufeinander auf und laufen in dieser Reihenfolge, weil sie
* sich eine Datenbank teilen (siehe playwright.config.ts, workers: 1).
*/
const ADMIN = { username: "chefin", password: "ein-langes-testpasswort" };
const ZWEITER = { username: "zweiter", password: "noch-ein-langes-passwort" };
async function login(request: APIRequestContext, user: { username: string; password: string }) {
const response = await request.post("/api/auth/login", { data: user });
expect(response.ok(), `Anmeldung als ${user.username}`).toBeTruthy();
}
test.describe.configure({ mode: "serial" });
test("Einrichtung legt das erste Konto an und sperrt sich danach", async ({ request }) => {
const response = await request.post("/api/auth/setup", {
data: { ...ADMIN, mode: "MULTI_USER" },
});
expect(response.status()).toBe(200);
expect((await response.json()).user.role).toBe("ADMIN");
// Ein zweiter Aufruf darf kein weiteres Adminkonto erzeugen.
const wieder = await request.post("/api/auth/setup", {
data: { username: "eindringling", password: "ein-langes-testpasswort" },
});
expect(wieder.status()).toBe(409);
});
test("ohne Anmeldung ist nichts zu holen", async ({ playwright }) => {
const anonym = await playwright.request.newContext({ baseURL: test.info().project.use.baseURL });
for (const pfad of ["/api/projects", "/api/connections", "/api/admin/users"]) {
expect((await anonym.get(pfad)).status(), pfad).toBe(401);
}
await anonym.dispose();
});
test("Fremdes Origin wird abgewiesen", async ({ request }) => {
await login(request, ADMIN);
const response = await request.post("/api/projects", {
data: { name: "Von woanders" },
headers: { Origin: "https://boese.example" },
});
expect(response.status()).toBe(403);
});
test("Content-Type wird erzwungen", async ({ request }) => {
await login(request, ADMIN);
const response = await request.post("/api/projects", {
headers: { "Content-Type": "text/plain" },
data: JSON.stringify({ name: "Umgehung" }),
});
expect(response.status()).toBe(415);
});
test("SSRF: interne Adressen werden geblockt", async ({ request }) => {
await login(request, ADMIN);
for (const ziel of ["http://127.0.0.1:5432", "http://169.254.169.254", "http://0.0.0.0"]) {
const response = await request.post("/api/connections", {
data: { name: `ssrf-${ziel}`, provider: "GITEA", baseUrl: ziel },
});
expect(response.status(), ziel).toBe(422);
}
});
test("Datentrennung zwischen zwei Konten", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = await request.post("/api/projects", {
data: { name: "Geheimprojekt", status: "IN_DEVELOPMENT" },
});
expect(angelegt.status()).toBe(201);
const projektId = (await angelegt.json()).project.id;
const aufgabe = await request.post(`/api/projects/${projektId}/tasks`, {
data: { title: "Nicht fuer fremde Augen" },
});
expect(aufgabe.status()).toBe(201);
const aufgabenId = (await aufgabe.json()).task.id;
// Zweites Konto anlegen und in einem eigenen Kontext anmelden.
const neu = await request.post("/api/admin/users", {
data: { ...ZWEITER, role: "USER" },
});
expect(neu.status()).toBe(201);
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/projects")).json()).projects).toHaveLength(0);
// Fremde IDs muessen 404 liefern, nicht 403 – sonst waere die Existenz
// des Datensatzes bestaetigt.
expect((await fremd.get(`/api/projects/${projektId}`)).status()).toBe(404);
expect(
(await fremd.patch(`/api/projects/${projektId}`, { data: { name: "uebernommen" } })).status(),
).toBe(404);
expect((await fremd.delete(`/api/projects/${projektId}`)).status()).toBe(404);
expect((await fremd.get(`/api/projects/${projektId}/tasks`)).status()).toBe(404);
expect((await fremd.patch(`/api/tasks/${aufgabenId}`, { data: { title: "x" } })).status()).toBe(
404,
);
// Adminwege bleiben zu.
expect((await fremd.get("/api/admin/users")).status()).toBe(403);
expect(
(await fremd.patch("/api/admin/settings", { data: { mode: "SINGLE_USER" } })).status(),
).toBe(403);
// Und das Original ist unveraendert.
const danach = await (await request.get(`/api/projects/${projektId}`)).json();
expect(danach.project.name).toBe("Geheimprojekt");
expect(danach.project.tasks).toHaveLength(1);
await fremd.dispose();
});
test("Aufgaben: Reihenfolge, abgeleiteter Fortschritt, fremde IDs", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Aufgabenprojekt" } })).json()
).project;
const titel = ["eins", "zwei", "drei"];
const ids: string[] = [];
for (const title of titel) {
const response = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title, status: "TODO" },
});
ids.push((await response.json()).task.id);
}
// Neue Aufgaben haengen sich hinten an.
const angelegt = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(angelegt.map((task: { position: number }) => task.position)).toEqual([0, 1, 2]);
// Umdrehen.
const gedreht = [...ids].reverse();
const reorder = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { TODO: gedreht } },
});
expect(reorder.ok()).toBeTruthy();
const nachher = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(nachher.map((task: { title: string }) => task.title)).toEqual(["drei", "zwei", "eins"]);
// Eine nach DONE – Fortschritt folgt, sobald eingeschaltet.
await request.patch(`/api/projects/${projekt.id}`, { data: { progressFromTasks: true } });
await request.patch(`/api/tasks/${ids[0]}`, { data: { status: "DONE" } });
const mitFortschritt = (
await (await request.get(`/api/projects/${projekt.id}`)).json()
).project;
expect(mitFortschritt.progress).toBe(33);
// Eine ID aus einem anderen Projekt darf nicht verschoben werden.
const anderes = (
await (await request.post("/api/projects", { data: { name: "Anderes" } })).json()
).project;
const fremdeAufgabe = (
await (
await request.post(`/api/projects/${anderes.id}/tasks`, { data: { title: "fremd" } })
).json()
).task;
const untergeschoben = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DOING: [fremdeAufgabe.id] } },
});
const ergebnis = await untergeschoben.json();
expect(ergebnis.updated).toBe(0);
expect(ergebnis.ignored).toBe(1);
const unveraendert = (
await (await request.get(`/api/projects/${anderes.id}/tasks`)).json()
).tasks[0];
expect(unveraendert.status).toBe("TODO");
});
test("Issues als Aufgaben uebernehmen, ohne Dubletten", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Issue-Projekt" } })).json()
).project;
// Ohne verknuepftes Repository gibt es nichts zu uebernehmen.
const ohneRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: {},
});
expect(ohneRepo.status()).toBe(409);
// Mit Repository, aber ohne geholten Stand ebenfalls nicht – der Server
// laedt hier bewusst nichts nach.
await request.patch(`/api/projects/${projekt.id}`, {
data: { repoUrl: "https://github.com/vercel/swr" },
});
const ohneStand = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: {},
});
expect(ohneStand.status()).toBe(409);
// Nach einem bestimmten, das es nicht gibt, ist dagegen 404 richtig.
const fremdesRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: { repositoryId: "gibt-es-nicht" },
});
expect(fremdesRepo.status()).toBe(404);
});
test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Beim Anlegen setzen die Git-Felder das fuehrende Repository.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Zwei Teile", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
expect(projekt.repositories).toHaveLength(1);
expect(projekt.repositories[0].repoOwner).toBe("vercel");
expect(projekt.repositories[0].repoName).toBe("swr");
// Ein zweites dazu.
const zweites = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { label: "Backend", repoUrl: "https://github.com/vercel/next.js" },
});
expect(zweites.status()).toBe(201);
const backendId = (await zweites.json()).repository.id;
// Dasselbe Repo zweimal ist immer ein Versehen.
const doppelt = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { repoUrl: "https://github.com/vercel/next.js" },
});
expect(doppelt.status()).toBe(409);
// Halbe Angaben ergeben kein Repository.
const leer = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { label: "Nur ein Name" },
});
expect(leer.status()).toBe(422);
const geladen = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(geladen.repositories).toHaveLength(2);
// Das fuehrende steht vorn.
expect(geladen.repositories[0].repoName).toBe("swr");
expect(geladen.repositories[1].label).toBe("Backend");
// Der Projekt-PATCH bearbeitet weiterhin nur das fuehrende.
await request.patch(`/api/projects/${projekt.id}`, { data: { autoSync: false } });
const danach = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(danach.repositories[0].autoSync).toBe(false);
expect(danach.repositories[1].autoSync).toBe(true);
// Fremde Konten kommen an kein Repository.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await fremd.get(`/api/projects/${projekt.id}/repositories`)).status()).toBe(404);
expect((await fremd.patch(`/api/repositories/${backendId}`, { data: {} })).status()).toBe(404);
expect((await fremd.delete(`/api/repositories/${backendId}`)).status()).toBe(404);
expect((await fremd.post(`/api/repositories/${backendId}/sync`)).status()).toBe(404);
await fremd.dispose();
// Entfernen laesst das andere stehen.
expect((await request.delete(`/api/repositories/${backendId}`)).ok()).toBeTruthy();
const zumSchluss = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(zumSchluss.repositories).toHaveLength(1);
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Konto anlegen und stilllegen.
const angelegt = await request.post("/api/admin/users", {
data: {
username: "stillgelegt",
password: "ein-ausreichend-langes-passwort",
role: "USER",
},
});
expect(angelegt.status()).toBe(201);
const id = (await angelegt.json()).user.id;
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
.toBeTruthy();
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Falsches Passwort auf dem stillgelegten Konto ...
const stillgelegt = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
});
// ... und auf einem Konto, das es gar nicht gibt.
const erfunden = await anonym.post("/api/auth/login", {
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
});
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
expect(stillgelegt.status()).toBe(erfunden.status());
expect(stillgelegt.status()).toBe(401);
expect(await stillgelegt.json()).toEqual(await erfunden.json());
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
const mitPasswort = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
});
expect(mitPasswort.status()).toBe(403);
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
await anonym.dispose();
await request.delete(`/api/admin/users/${id}`);
});
test("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => {
await login(request, ADMIN);
// Ein oeffentliches Repository mit sicher geschlossenen Issues. Der
// Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title: "Haengt an einem geschlossenen Issue" },
});
const taskId = (await angelegt.json()).task.id;
/**
* Die Verknuepfung direkt in der Datenbank setzen.
*
* Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als
* Issue anlegen" wuerde ein neues Issue in einem fremden Repository
* erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im
* Produktionscode – dafuer greift der Test lieber selbst zur Datenbank.
*
* Issue 1 von vercel/swr ist seit Jahren geschlossen.
*/
const prisma = new PrismaClient({
datasources: { db: { url: DATABASE_URL } },
});
try {
await prisma.task.update({
where: { id: taskId },
data: { sourceRepositoryId: repoId, sourceIssue: 1 },
});
} finally {
await prisma.$disconnect();
}
const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const alsAngelegt = vorher.project.tasks.find(
(t: { id: string }) => t.id === taskId,
);
expect(alsAngelegt.sourceIssue).toBe(1);
expect(alsAngelegt.status).toBe("TODO");
// Abgleich anstossen – er fragt den Zustand des Issues nach.
const sync = await request.post(`/api/repositories/${repoId}/sync`);
const syncBody = await sync.json();
/**
* Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht
* erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt
* rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen.
*/
if (!sync.ok()) {
test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`);
return;
}
const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId);
expect(abgehakt.status).toBe("DONE");
expect(abgehakt.doneAt).not.toBeNull();
// Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt,
// ohne dass erneut nachgefragt wird.
const nochmal = await request.post(`/api/repositories/${repoId}/sync`);
expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN);
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Hakenprojekt", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
// Ohne eingerichteten Webhook ist der Endpunkt stumm – und zwar mit 404,
// damit die ID nicht bestaetigt wird.
const ohne = await request.post(`/api/webhooks/${repoId}`, {
headers: { "Content-Type": "application/json" },
data: { action: "closed", issue: { number: 1 } },
});
expect(ohne.status()).toBe(404);
// Einrichten liefert das Geheimnis genau einmal zurueck.
const eingerichtet = await request.post(`/api/repositories/${repoId}/webhook`);
expect(eingerichtet.status()).toBe(200);
const { secret, url } = await eingerichtet.json();
expect(typeof secret).toBe("string");
expect(secret.length).toBeGreaterThanOrEqual(64);
expect(url).toContain(`/api/webhooks/${repoId}`);
// Und danach nirgends mehr: weder im Projekt noch am Repository.
const geladen = await (await request.get(`/api/projects/${projekt.id}`)).json();
expect(JSON.stringify(geladen)).not.toContain(secret);
expect(geladen.project.repositories[0].webhookSecret).toBeUndefined();
// Eine Aufgabe, die an einem Issue haengt – von Hand gesetzt gibt es
// nicht, also ueber die Uebernahme aus dem Snapshot. Stattdessen prueft
// dieser Test die Grenze selbst: ohne gueltige Signatur passiert nichts.
const rumpf = JSON.stringify({ action: "closed", issue: { number: 4242 } });
const falsch = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": "sha256=00",
},
data: rumpf,
});
expect(falsch.status()).toBe(401);
const gar_keine = await request.post(`/api/webhooks/${repoId}`, {
headers: { "Content-Type": "application/json", "X-GitHub-Event": "issues" },
data: rumpf,
});
expect(gar_keine.status()).toBe(401);
// Mit richtiger Signatur wird der Aufruf angenommen. Zu Issue 4242 gibt
// es keine Aufgabe – erwartet ist also "angenommen, nichts zugeordnet".
const { createHmac } = await import("node:crypto");
const signatur = createHmac("sha256", secret).update(rumpf, "utf8").digest("hex");
const richtig = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: rumpf,
});
expect(richtig.status()).toBe(200);
const antwort = await richtig.json();
expect(antwort).toMatchObject({ ok: true, handled: "issue", matched: false });
// Dieselbe Signatur zu einem anderen Rumpf gilt nicht.
const veraendert = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: JSON.stringify({ action: "closed", issue: { number: 9999 } }),
});
expect(veraendert.status()).toBe(401);
// Fremde Konten koennen den Haken weder einrichten noch zuruecknehmen.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await fremd.post(`/api/repositories/${repoId}/webhook`)).status()).toBe(404);
expect((await fremd.delete(`/api/repositories/${repoId}/webhook`)).status()).toBe(404);
await fremd.dispose();
// Zuruecknehmen schliesst den Endpunkt wieder.
expect((await request.delete(`/api/repositories/${repoId}/webhook`)).ok()).toBeTruthy();
const danach = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: rumpf,
});
expect(danach.status()).toBe(404);
});
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await geloescht.json()).changed).toBe(3);
});
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
).project;
const woechentlich = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, {
data: {
title: "Abhaengigkeiten pruefen",
dueDate: "2026-07-06T12:00:00",
recurrence: "WEEKLY",
labels: ["wartung"],
},
})
).json()
).task;
// Ein Zwischenschritt loest noch nichts aus.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(1);
// Erst der Uebergang nach ERLEDIGT.
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
data: { status: "DONE" },
});
expect((await erledigt.json()).next).not.toBeNull();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
expect(folge.status).toBe("TODO");
expect(folge.title).toBe("Abhaengigkeiten pruefen");
expect(folge.labels).toEqual(["wartung"]);
expect(folge.recurrence).toBe("WEEKLY");
expect(folge.recurredFrom).toBe(woechentlich.id);
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
// Erneut speichern erzeugt keine dritte.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DONE: [folge.id] } },
});
expect(gezogen.ok()).toBeTruthy();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(3);
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
// Eine einmalige Aufgabe bleibt einmalig.
const einmalig = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
).json()
).task;
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(4);
});
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Projekt herrichten, das als Vorlage taugt.
const quelle = (
await (
await request.post("/api/projects", {
data: { name: "Vorlagenquelle", status: "PLANNING", priority: 3, tags: ["web", "docker"] },
})
).json()
).project;
await request.post(`/api/projects/${quelle.id}/notes`, {
data: { content: "Checkliste vor dem Start." },
});
const erledigt = (
await (
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Schon fertig" } })
).json()
).task;
await request.patch(`/api/tasks/${erledigt.id}`, { data: { status: "DONE" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Repo anlegen" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "CI einrichten" } });
const gesichert = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App", description: "Mit Docker und CI" },
});
expect(gesichert.status()).toBe(201);
const vorlage = (await gesichert.json()).template;
expect(vorlage.tags).toEqual(["web", "docker"]);
expect(vorlage.status).toBe("PLANNING");
expect(vorlage.notes).toHaveLength(1);
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu tun ist.
expect(vorlage.tasks).toHaveLength(2);
expect(vorlage.tasks.map((task: { title: string }) => task.title)).not.toContain("Schon fertig");
// Derselbe Name ein zweites Mal wird abgewiesen.
const doppelt = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App" },
});
expect(doppelt.status()).toBe(409);
// Anwenden: Notizen und Aufgaben entstehen mit dem Projekt.
const neu = (
await (
await request.post("/api/projects", {
data: { name: "Aus Vorlage", status: "IDEA", templateId: vorlage.id },
})
).json()
).project;
const geladen = (await (await request.get(`/api/projects/${neu.id}`)).json()).project;
expect(geladen.notes).toHaveLength(1);
expect(geladen.tasks).toHaveLength(2);
// Die Reihenfolge der Vorlage bleibt erhalten.
expect(geladen.tasks.map((task: { title: string }) => task.title)).toEqual([
"Repo anlegen",
"CI einrichten",
]);
// Der ausdrueckliche Status gewinnt gegen den der Vorlage.
expect(geladen.status).toBe("IDEA");
// Der Verlauf haelt die Herkunft fest.
const verlauf = (await (await request.get(`/api/projects/${neu.id}/activity`)).json()).activities;
expect(verlauf.some((entry: { summary: string }) => entry.summary.includes("Web-App"))).toBe(true);
// Eine fremde Vorlage laesst sich nicht unterschieben.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/templates")).json()).templates).toHaveLength(0);
const untergeschoben = await fremd.post("/api/projects", {
data: { name: "Klau", templateId: vorlage.id },
});
expect(untergeschoben.status()).toBe(404);
expect((await fremd.delete(`/api/templates/${vorlage.id}`)).status()).toBe(404);
await fremd.dispose();
// Und die Vorlage ueberlebt das Loeschen ihres Ursprungsprojekts.
await request.delete(`/api/projects/${quelle.id}`);
const nochDa = (await (await request.get("/api/templates")).json()).templates;
expect(nochDa).toHaveLength(1);
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
});
test("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request, playwright }) => {
await login(request, ADMIN);
// Die bisherigen Tests haben reichlich Verlauf erzeugt. Erst alles auf
// einmal holen, dann in kleinen Seiten – beides muss dieselbe Menge sein.
const alles = await (await request.get("/api/activity?limit=100")).json();
expect(alles.activities.length).toBeGreaterThan(5);
const ids: string[] = [];
let cursor: { vor: string; id: string } | null = null;
for (let seite = 0; seite < 60; seite += 1) {
const query = cursor
? `?limit=3&vor=${encodeURIComponent(cursor.vor)}&id=${encodeURIComponent(cursor.id)}`
: "?limit=3";
const antwort = await request.get(`/api/activity${query}`);
expect(antwort.ok()).toBeTruthy();
const seiteBody = await antwort.json();
ids.push(...seiteBody.activities.map((eintrag: { id: string }) => eintrag.id));
if (!seiteBody.weitere) break;
cursor = seiteBody.naechster;
expect(cursor).not.toBeNull();
}
// Der eigentliche Punkt: keine Dubletten und keine Luecken, auch wenn
// mehrere Eintraege denselben Zeitstempel tragen.
expect(new Set(ids).size).toBe(ids.length);
expect(ids.length).toBe(alles.activities.length);
// Absteigend sortiert.
const zeiten: number[] = alles.activities.map((eintrag: { createdAt: string }) =>
Date.parse(eintrag.createdAt),
);
for (let i = 1; i < zeiten.length; i += 1) {
expect(zeiten[i]).toBeLessThanOrEqual(zeiten[i - 1]);
}
// Unbrauchbare Cursor fangen von vorn an, statt zu scheitern.
const kaputt = await request.get("/api/activity?vor=keindatum&id=x");
expect(kaputt.ok()).toBeTruthy();
expect((await kaputt.json()).activities.length).toBeGreaterThan(0);
// Und ein fremdes Konto sieht ausschliesslich seinen eigenen Verlauf.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const fremderVerlauf = await (await fremd.get("/api/activity")).json();
const eigeneIds = new Set(ids);
for (const eintrag of fremderVerlauf.activities) {
expect(eigeneIds.has(eintrag.id)).toBe(false);
}
await fremd.dispose();
});
test("Volltextsuche findet auch Aufgaben", async ({ request, playwright }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Suchprojekt" } })).json()
).project;
for (const task of [
{
title: "Dockerfile aufraeumen",
description: "Die Schichten im Container neu ordnen, damit der Bau schneller wird.",
},
{ title: "Rechnungen exportieren", description: "Monatlicher Export als CSV." },
{ title: "Erledigtes mit Container", description: "Auch das soll gefunden werden.", status: "DONE" },
{ title: "Ohne Bezug", description: "Voellig anderer Inhalt." },
]) {
expect((await request.post(`/api/projects/${projekt.id}/tasks`, { data: task })).status()).toBe(
201,
);
}
// Titel und Beschreibung werden durchsucht.
const container = await (await request.get("/api/search?q=Container")).json();
const titel = container.tasks.map((treffer: { title: string }) => treffer.title);
expect(titel).toContain("Dockerfile aufraeumen");
// Erledigtes bleibt drin – wer sucht, sucht oft gerade das Abgehakte.
expect(titel).toContain("Erledigtes mit Container");
expect(titel).not.toContain("Ohne Bezug");
// Deutsche Stammformen: "Rechnung" findet "Rechnungen".
const rechnung = await (await request.get("/api/search?q=Rechnung")).json();
expect(rechnung.tasks.map((t: { title: string }) => t.title)).toContain(
"Rechnungen exportieren",
);
// Das Projekt taucht in projectIds auf, damit das Brett es einblendet.
expect(container.projectIds).toContain(projekt.id);
// Zu kurze Eingaben liefern leere Listen statt Unsinn.
const kurz = await (await request.get("/api/search?q=a")).json();
expect(kurz.tasks).toEqual([]);
expect(kurz.hits).toEqual([]);
// Und ein fremdes Konto findet nichts davon.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/search?q=Container")).json()).tasks).toHaveLength(0);
await fremd.dispose();
});
test("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
// nirgends – der Endpunkt darf ohne Anmeldung nicht antworten.
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
maxRedirects: 0,
});
const antwort = await anonym.get("/_next/image?url=%2Ficon-192.png&w=64&q=75");
expect(antwort.status(), "kein 200 ohne Anmeldung").not.toBe(200);
await anonym.dispose();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN);
await request.post("/api/connections", {
data: {
name: "Mit Token",
provider: "GITEA",
baseUrl: "https://gitea.example.com",
token: "streng-geheimes-token",
},
});
const roh = await (await request.get("/api/projects/export")).text();
expect(roh).not.toContain("streng-geheimes-token");
expect(roh).not.toContain("tokenCipher");
expect(roh).not.toContain("ownerId");
const datei = JSON.parse(roh);
const vorher = datei.projects.length;
// Version 2: die Repositories stehen als Liste in der Datei.
expect(datei.version).toBe(2);
const mitRepo = datei.projects.find((entry: { name: string }) => entry.name === "Zwei Teile");
expect(mitRepo.repositories).toHaveLength(1);
expect(mitRepo.repositories[0].repoName).toBe("swr");
// Ueberspringen aendert nichts.
const uebersprungen = await request.post("/api/projects/import", {
data: { data: datei, strategy: "skip", dryRun: false },
});
expect((await uebersprungen.json()).summary.skipped).toBe(vorher);
// Zusaetzlich anlegen verdoppelt.
const zusaetzlich = await request.post("/api/projects/import", {
data: { data: datei, strategy: "rename", dryRun: false },
});
expect((await zusaetzlich.json()).summary.renamed).toBe(vorher);
const jetzt = (await (await request.get("/api/projects")).json()).projects;
expect(jetzt).toHaveLength(vorher * 2);
// Muell wird abgewiesen.
const muell = await request.post("/api/projects/import", {
data: { data: { irgendwas: true }, strategy: "skip", dryRun: true },
});
expect(muell.status()).toBe(422);
});
test("Dateien der Version 1 bleiben lesbar", async ({ request }) => {
await login(request, ADMIN);
// Damals stand genau ein Repository flach am Projekt. Wer eine solche
// Sicherung liegen hat, soll sie weiterhin einspielen koennen.
const alt = {
kind: "nebula.projects",
version: 1,
projects: [
{
name: "Aus Version eins",
repoUrl: "https://github.com/vercel/swr",
repoOwner: "vercel",
repoName: "swr",
autoSync: false,
},
],
};
const gelesen = await request.post("/api/projects/import", {
data: { data: alt, strategy: "rename", dryRun: false },
});
expect(gelesen.ok()).toBeTruthy();
const projekte = (await (await request.get("/api/projects")).json()).projects;
const uebernommen = projekte.find(
(entry: { name: string }) => entry.name === "Aus Version eins",
);
expect(uebernommen.repositories).toHaveLength(1);
expect(uebernommen.repositories[0].repoOwner).toBe("vercel");
expect(uebernommen.repositories[0].autoSync).toBe(false);
});
+62
View File
@@ -0,0 +1,62 @@
import { execFileSync } from "node:child_process";
import { PrismaClient } from "@prisma/client";
/**
* Bereitet die Testdatenbank vor: Schema einspielen und leeren.
*
* Die Datenbank muss vorher existieren. Anlegen ist bewusst nicht Teil des
* Setups – in CI erzeugt sie der Postgres-Dienst, oertlich einmalig:
*
* docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test"
*/
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
/**
* Reihenfolge egal, weil CASCADE – aber vollstaendig muss sie sein, sonst
* schleppen Laeufe Reste mit.
*/
const TABLES = [
"Task",
"Note",
"RepoSnapshot",
"Project",
"Credential",
"Session",
"WebAuthnChallenge",
"GitConnection",
"User",
"AppSettings",
];
export default async function globalSetup() {
const name = new URL(DATABASE_URL).pathname.replace(/^\//, "");
// Schutz vor dem teuersten aller Fehler: eine Testreihe, die die echte
// Datenbank leert. Der Name muss "test" enthalten, sonst passiert nichts.
if (!name.includes("test")) {
throw new Error(
`Die Testdatenbank heisst "${name}" und enthaelt nicht "test". ` +
"Aus Sicherheitsgruenden abgebrochen – E2E_DATABASE_URL pruefen.",
);
}
console.log(`[e2e] Datenbank: ${name}`);
execFileSync("npx", ["prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL },
stdio: "pipe",
shell: process.platform === "win32",
});
const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } });
try {
const list = TABLES.map((table) => `"${table}"`).join(", ");
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`);
console.log("[e2e] Datenbank geleert.");
} finally {
await prisma.$disconnect();
}
}
+391
View File
@@ -0,0 +1,391 @@
import { expect, test } from "@playwright/test";
/**
* Ein Weg durch die Oberflaeche, wie ihn ein Mensch geht: anmelden, Projekt
* anlegen, Aufgaben verwalten. Keine Vollabdeckung – die Wege, die kaputt
* gehen duerfen, ohne dass es jemand merkt.
*
* Setzt voraus, dass api.spec.ts das Konto bereits angelegt hat; laeuft
* alphabetisch danach.
*/
const KONTO = { username: "chefin", password: "ein-langes-testpasswort" };
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.fill("#login-username", KONTO.username);
await page.fill("#login-password", KONTO.password);
await page.click('button[type="submit"]');
await expect(page.getByText("Vibe Coding Kontrollzentrum")).toBeVisible();
});
test("Dashboard zeigt Kennzahlen und Projekte", async ({ page }) => {
await expect(page.getByRole("heading", { name: /Deine Projekte/ })).toBeVisible();
await expect(page.getByText("Projekte", { exact: false }).first()).toBeVisible();
// Keine unbeabsichtigten Fehler in der Konsole.
expect(page.url()).toContain("/");
});
test("Projekt anlegen und wiederfinden", async ({ page }) => {
await page.getByRole("button", { name: /Neues Projekt/ }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.fill("#project-name", "Oberflaechentest");
await page.fill("#project-summary", "Von Playwright angelegt.");
await page.getByRole("button", { name: "Projekt anlegen" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
await expect(page.getByText("Oberflaechentest")).toBeVisible();
});
test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
// Zwei Aufgaben ueber den Dialog anlegen.
for (const titel of ["Erste Aufgabe", "Zweite Aufgabe"]) {
await page.getByRole("button", { name: "Aufgabe", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.fill("#task-title", titel);
await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
}
const offen = page.locator('[data-task-column="TODO"]');
const inArbeit = page.locator('[data-task-column="DOING"]');
await expect(offen.getByText("Erste Aufgabe")).toBeVisible();
await expect(offen.getByText("Zweite Aufgabe")).toBeVisible();
// Erste Aufgabe nach "In Arbeit" ziehen.
const griff = offen.locator('button[aria-label$="verschieben"]').first();
const von = await griff.boundingBox();
const nach = await inArbeit.boundingBox();
expect(von && nach).toBeTruthy();
await page.mouse.move(von!.x + von!.width / 2, von!.y + von!.height / 2);
await page.mouse.down();
// Erst ueber die Aktivierungsschwelle des Sensors (5 Pixel) hinaus.
await page.mouse.move(von!.x + 30, von!.y + 10, { steps: 6 });
await page.mouse.move(nach!.x + nach!.width / 2, nach!.y + 60, { steps: 20 });
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert.
await page.reload();
await expect(
page.locator('[data-task-column="DOING"]').getByText("Erste Aufgabe"),
).toBeVisible();
});
test("Volltextsuche findet Notizen", async ({ page }) => {
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
await page.getByRole("button", { name: "Notiz", exact: true }).click();
await page.getByPlaceholder(/Gedanken/).fill("Suchbegriff Zaunkoenig steht hier.");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText("Zaunkoenig", { exact: false })).toBeVisible();
// exact, sonst trifft es auch "Zurueck zum Dashboard".
await page.getByRole("link", { name: "Dashboard", exact: true }).click();
await page.getByPlaceholder(/durchsuchen/).fill("Zaunkoenig");
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
});
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Liste" }).click();
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
// Seite liess sich seitwaerts schieben.
const masse = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
const knopf = page.getByRole("button", { name: "Neues Projekt" });
await expect(knopf).toBeVisible();
const kasten = await knopf.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
});
test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Idee: nur der Name, alles andere kommt aus den Vorgaben des Schemas.
await page.fill("#quick-title", "Aus der Bahn notiert");
await dialog.getByRole("button", { name: "Anlegen" }).click();
// Der Dialog bleibt offen und bestaetigt – das ist der Punkt der
// Schnellerfassung: die naechste Idee soll ohne Umweg hineingehen.
await expect(dialog.getByText("Angelegt")).toBeVisible();
await expect(dialog.getByRole("link", { name: "Aus der Bahn notiert" })).toBeVisible();
await expect(page.locator("#quick-title")).toHaveValue("");
// Aufgabe: derselbe Dialog, ein Feld mehr.
await dialog.getByRole("button", { name: "Aufgabe", exact: true }).click();
await page.fill("#quick-title", "Unterwegs eingefallen");
await expect(page.locator("#quick-project")).toBeVisible();
await dialog.getByRole("button", { name: "Anlegen" }).click();
await expect(dialog.getByRole("link", { name: "Unterwegs eingefallen" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
// Die Idee steht als Projekt im Dashboard.
await page.goto("/");
await expect(page.getByText("Aus der Bahn notiert")).toBeVisible();
});
test("Dialoge weichen der Bildschirmtastatur aus", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const TASTATUR = 336;
/**
* Eine echte Tastatur laesst sich nicht einblenden. Stattdessen meldet
* visualViewport eine kleinere Hoehe – genau die Schnittstelle, an der
* der Dialog haengt. Ueberschrieben wird am echten Objekt: der Dialog
* merkt sich beim Oeffnen eine Referenz darauf.
*/
await page.evaluate((tastatur) => {
const fenster = window.visualViewport;
if (!fenster) throw new Error("visualViewport fehlt");
const hoehe = window.innerHeight - tastatur;
Object.defineProperty(fenster, "height", { get: () => hoehe, configurable: true });
Object.defineProperty(fenster, "offsetTop", { get: () => 0, configurable: true });
fenster.dispatchEvent(new Event("resize"));
}, TASTATUR);
await page.waitForTimeout(300);
// Eingabefeld und Absendeknopf muessen oberhalb der Tastatur liegen.
// Vorher stand der Dialog mittig im vollen Fenster – und damit zur
// Haelfte darunter.
const frei = 667 - TASTATUR;
for (const ziel of [
page.locator("#quick-title"),
page.getByRole("dialog").getByRole("button", { name: "Anlegen" }),
]) {
const kasten = await ziel.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.y + kasten!.height).toBeLessThanOrEqual(frei);
}
await page.keyboard.press("Escape");
});
test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
// Die zuvor per Schnellerfassung angelegte Aufgabe taucht hier auf,
// obwohl sie in einem anderen Projekt haengt.
const zeile = page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" });
await expect(zeile).toBeVisible();
// Abhaken aus der Uebersicht heraus.
await zeile.getByRole("button", { name: /abhaken/ }).click();
// Ohne "Erledigte einblenden" verschwindet sie damit aus der Liste.
await expect(zeile).toBeHidden();
await page.getByLabel("Erledigte einblenden").check();
await expect(
page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }),
).toBeVisible();
});
test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => {
// Erreichbar vom Dashboard aus, ohne vierten Punkt in der Kopfleiste.
await page.getByRole("link", { name: /Wochenrückblick/ }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
await expect(page.getByRole("heading", { name: "Diese Woche", level: 1 })).toBeVisible();
// Die Vorgaenge der bisherigen Tests liegen in dieser Woche, es muss also
// etwas dastehen – und das Projekt gehoert an jede Zeile.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
// Blättern: zurück und wieder her.
await page.getByRole("link", { name: "Eine Woche zurück" }).click();
await expect(page).toHaveURL(/woche=-1/);
await expect(page.getByRole("heading", { name: "Vorige Woche", level: 1 })).toBeVisible();
await page.getByRole("link", { name: "Diese Woche", exact: true }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/^Projekt, /).fill("Rückblick");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/rueckblick/);
});
test("Globale Zeitleiste zeigt den Verlauf ueber alle Projekte", async ({ page }) => {
// Vom Wochenrueckblick aus verlinkt.
await page.goto("/rueckblick");
await page.getByRole("link", { name: /Ganzer Verlauf/ }).click();
await expect(page).toHaveURL(/\/verlauf$/);
await expect(page.getByRole("heading", { name: "Verlauf", level: 1 })).toBeVisible();
// Eintraege mit Projektbezug – ohne den ist eine uebergreifende Liste
// nicht zu lesen.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
await expect(page.getByText(/Vorgänge geladen|Vorgang geladen/)).toBeVisible();
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/^Projekt, /).fill("Verlauf");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/verlauf/);
});
test("Aufgabendashboard laesst sich durchsuchen", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
const suchfeld = page.getByLabel("Aufgaben durchsuchen");
await expect(suchfeld).toBeVisible();
// Aus den frueheren Tests gibt es "Erste Aufgabe" und "Zweite Aufgabe".
await suchfeld.fill("Erste");
await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible();
await expect(page.getByRole("listitem").filter({ hasText: "Zweite Aufgabe" })).toBeHidden();
// Ohne Treffer erklaert der Leerzustand, worin gesucht wurde.
await suchfeld.fill("gibtesnichtxyz");
await expect(page.getByText("Keine Treffer")).toBeVisible();
await expect(page.getByText(/Titel, Beschreibung, Labels und Projektname/)).toBeVisible();
// Zuruecksetzen bringt alles wieder.
await page.getByRole("button", { name: "Suche zuruecksetzen" }).click();
await expect(suchfeld).toHaveValue("");
await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible();
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
// Ohne Eingabe stehen Projekte und Bereiche bereit.
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
await page.getByPlaceholder(/^Projekt, /).fill("Zaunkoenig");
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
// Enter springt zum ersten Treffer.
await page.keyboard.press("Enter");
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
// Escape schliesst wieder.
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
});
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click();
await expect(page).toHaveURL(/\/login/);
// Und die geschuetzte Seite ist wieder zu.
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
});
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+16 -6
View File
@@ -7,13 +7,23 @@ const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
reactStrictMode: true, reactStrictMode: true,
poweredByHeader: false, poweredByHeader: false,
experimental: { // Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
serverActions: {
bodySizeLimit: "2mb",
},
},
serverExternalPackages: ["@prisma/client", "prisma"], serverExternalPackages: ["@prisma/client", "prisma"],
images: {
/**
* Der Bildoptimierer ist abgeschaltet.
*
* Nebula benutzt next/image nirgends – die paar Symbole sind SVG und
* PNG in fester Groesse. Der Endpunkt /_next/image waere aber trotzdem
* da, und zwar ohne Anmeldung: die Middleware nimmt ihn aus, weil dort
* sonst nur Rechenzeit verbraucht wuerde.
*
* Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
* regelmaessig Schwachstellen gibt (aktuell CVE-2026-33327 und drei
* weitere). Eine Angriffsflaeche ohne Gegenwert – also weg damit.
*/
unoptimized: true,
},
}; };
export default nextConfig; export default nextConfig;
+5839
View File
File diff suppressed because it is too large Load Diff
+25 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.1.0", "version": "1.10.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
@@ -16,35 +16,53 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"docker:db": "docker compose up -d db", "docker:db": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db",
"docker:db:down": "docker compose stop db", "docker:db:down": "docker compose stop db",
"docker:up": "docker compose --profile full up -d --build", "docker:up": "docker compose --profile full up -d --build",
"docker:down": "docker compose --profile full down", "docker:down": "docker compose --profile full down",
"docker:logs": "docker compose --profile full logs -f app", "docker:logs": "docker compose --profile full logs -f app",
"docker:tools": "docker compose --profile tools up -d", "docker:tools": "docker compose --profile tools up -d",
"setup": "node scripts/setup.mjs", "setup": "node scripts/setup.mjs",
"postinstall": "prisma generate" "postinstall": "prisma generate",
}, "test": "vitest run",
"prisma": { "test:watch": "vitest",
"seed": "tsx prisma/seed.ts" "test:e2e": "npm run build && playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm test && npm run test:e2e"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.8.2", "@prisma/client": "^6.8.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.3",
"qrcode": "^1.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"zod": "^3.25.28" "zod": "^3.25.28"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21", "@types/node": "^22.15.21",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"vitest": "^4.1.10"
}, },
"engines": { "engines": {
"node": ">=20.11" "node": ">=20.11"
+63
View File
@@ -0,0 +1,63 @@
import { defineConfig } from "@playwright/test";
/**
* Ende-zu-Ende-Tests gegen die echte Anwendung.
*
* Playwright startet den Produktionsserver selbst und richtet ihn auf eine
* eigene Datenbank. Der Port ist bewusst ein anderer als 3000 und 3100,
* damit eine laufende Instanz nicht stoert.
*
* Voraussetzung: ein Bau (`npm run build`) und eine leere Datenbank, deren
* Name "test" enthaelt – siehe global-setup.ts. Beides erledigt
* `npm run test:e2e`.
*/
const PORT = Number(process.env.E2E_PORT ?? 3111);
const BASE_URL = `http://localhost:${PORT}`;
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
export default defineConfig({
testDir: "./e2e",
// Die Tests teilen sich eine Datenbank, deshalb nacheinander.
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
timeout: 60_000,
expect: { timeout: 10_000 },
globalSetup: "./e2e/global-setup.ts",
use: {
baseURL: BASE_URL,
locale: "de-DE",
timezoneId: "Europe/Berlin",
trace: "retain-on-failure",
screenshot: "only-on-failure",
// Oertlich der installierte Chrome, damit kein Browser geladen werden
// muss. In CI gibt es keinen – dort das mitgelieferte Chromium
// (siehe "playwright install chromium" im Arbeitsablauf).
...(process.env.CI ? {} : { channel: "chrome" as const }),
},
webServer: {
command: "npm start",
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
env: {
DATABASE_URL,
PORT: String(PORT),
APP_URL: BASE_URL,
NODE_ENV: "production",
// Fester Schluessel, damit Laeufe vergleichbar bleiben.
APP_SECRET: "e2e-test-secret-nur-fuer-die-testlaeufe-1234567890",
},
},
});
+24
View File
@@ -0,0 +1,24 @@
import { config as loadEnv } from "dotenv";
import { defineConfig } from "prisma/config";
/**
* Konfiguration der Prisma-CLI.
*
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
* wegfaellt.
*
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
* dann .env.local: eine oertliche Abweichung soll gewinnen.
*/
loadEnv({ path: ".env", quiet: true });
loadEnv({ path: ".env.local", override: true, quiet: true });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,132 @@
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('IDEA', 'OPEN', 'PLANNING', 'IN_DEVELOPMENT', 'DONE', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "GitProvider" AS ENUM ('GITHUB', 'GITLAB', 'GITEA');
-- CreateTable
CREATE TABLE "GitConnection" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" "GitProvider" NOT NULL,
"baseUrl" TEXT NOT NULL,
"username" TEXT,
"tokenCipher" TEXT,
"tokenHint" TEXT,
"lastCheckedAt" TIMESTAMP(3),
"lastCheckOk" BOOLEAN,
"lastCheckMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GitConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"summary" TEXT,
"description" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
"priority" INTEGER NOT NULL DEFAULT 2,
"progress" INTEGER NOT NULL DEFAULT 0,
"accent" TEXT NOT NULL DEFAULT 'violet',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"favorite" BOOLEAN NOT NULL DEFAULT false,
"repoUrl" TEXT,
"repoOwner" TEXT,
"repoName" TEXT,
"defaultBranch" TEXT,
"connectionId" TEXT,
"lastSyncedAt" TIMESTAMP(3),
"syncError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Note" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT,
"content" TEXT NOT NULL,
"pinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RepoSnapshot" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"fullName" TEXT,
"description" TEXT,
"defaultBranch" TEXT,
"htmlUrl" TEXT,
"visibility" TEXT,
"stars" INTEGER NOT NULL DEFAULT 0,
"forks" INTEGER NOT NULL DEFAULT 0,
"openIssues" INTEGER NOT NULL DEFAULT 0,
"openPullRequests" INTEGER NOT NULL DEFAULT 0,
"sizeKb" INTEGER NOT NULL DEFAULT 0,
"lastCommitSha" TEXT,
"lastCommitMessage" TEXT,
"lastCommitAuthor" TEXT,
"lastCommitAt" TIMESTAMP(3),
"latestTag" TEXT,
"latestRelease" TEXT,
"latestReleaseAt" TIMESTAMP(3),
"branchCount" INTEGER NOT NULL DEFAULT 0,
"tagCount" INTEGER NOT NULL DEFAULT 0,
"branches" JSONB NOT NULL DEFAULT '[]',
"tags" JSONB NOT NULL DEFAULT '[]',
"commits" JSONB NOT NULL DEFAULT '[]',
"releases" JSONB NOT NULL DEFAULT '[]',
"languages" JSONB NOT NULL DEFAULT '{}',
"pushedAt" TIMESTAMP(3),
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RepoSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GitConnection_name_key" ON "GitConnection"("name");
-- CreateIndex
CREATE INDEX "GitConnection_provider_idx" ON "GitConnection"("provider");
-- CreateIndex
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
-- CreateIndex
CREATE INDEX "Project_status_idx" ON "Project"("status");
-- CreateIndex
CREATE INDEX "Project_connectionId_idx" ON "Project"("connectionId");
-- CreateIndex
CREATE INDEX "Project_updatedAt_idx" ON "Project"("updatedAt");
-- CreateIndex
CREATE INDEX "Note_projectId_idx" ON "Note"("projectId");
-- CreateIndex
CREATE INDEX "Note_pinned_idx" ON "Note"("pinned");
-- CreateIndex
CREATE UNIQUE INDEX "RepoSnapshot_projectId_key" ON "RepoSnapshot"("projectId");
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,143 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
-- CreateEnum
CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER');
-- DropIndex
DROP INDEX "GitConnection_name_key";
-- DropIndex
DROP INDEX "Project_slug_key";
-- AlterTable
ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT;
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"displayName" TEXT,
"email" TEXT,
"passwordHash" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'USER',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"failedLoginCount" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userAgent" TEXT,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Credential" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"counter" INTEGER NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deviceType" TEXT,
"backedUp" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Credential_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebAuthnChallenge" (
"id" TEXT NOT NULL,
"challenge" TEXT NOT NULL,
"userId" TEXT,
"purpose" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AppSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER',
"allowRegistration" BOOLEAN NOT NULL DEFAULT false,
"rpName" TEXT NOT NULL DEFAULT 'Nebula',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId");
-- CreateIndex
CREATE INDEX "Credential_userId_idx" ON "Credential"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge");
-- CreateIndex
CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt");
-- CreateIndex
CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name");
-- CreateIndex
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
ADD COLUMN "lastAutoSyncReport" JSONB;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,16 @@
-- Volltextsuche ueber Notizen.
--
-- Dies ist ein Ausdrucksindex. Prisma kann so etwas im Schema nicht abbilden
-- und kennt ihn deshalb nicht. Nachgeprueft: "prisma migrate diff" laesst ihn
-- in Ruhe und schlaegt kein DROP vor - Ausdrucksindizes werden schlicht
-- ignoriert. Trotzdem beim Erzeugen kuenftiger Migrationen kurz hinsehen.
--
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
-- Postgres berechnet to_tsvector dann je Zeile.
--
-- Der Ausdruck muss mit dem in src/app/api/search/route.ts uebereinstimmen,
-- sonst greift der Index nicht. Die zweistellige Form von to_tsvector mit
-- fester Konfiguration ist IMMUTABLE und damit indizierbar.
CREATE INDEX "Note_fulltext_idx" ON "Note"
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || "content"));
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "ciBranch" TEXT,
ADD COLUMN "ciFinishedAt" TIMESTAMP(3),
ADD COLUMN "ciName" TEXT,
ADD COLUMN "ciState" TEXT,
ADD COLUMN "ciUrl" TEXT,
ADD COLUMN "commitActivity" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "issues" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "pullRequests" JSONB NOT NULL DEFAULT '[]';
@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'DOING', 'BLOCKED', 'DONE');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "progressFromTasks" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
"position" INTEGER NOT NULL DEFAULT 0,
"dueDate" TIMESTAMP(3),
"labels" TEXT[] DEFAULT ARRAY[]::TEXT[],
"doneAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Task_projectId_status_position_idx" ON "Task"("projectId", "status", "position");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,39 @@
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('WEBHOOK', 'NTFY', 'EMAIL');
-- CreateEnum
CREATE TYPE "NotificationEvent" AS ENUM ('SYNC_FAILED', 'CI_FAILED', 'NEW_RELEASE');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "notifiedCiState" TEXT,
ADD COLUMN "notifiedRelease" TEXT,
ADD COLUMN "notifiedSyncError" TEXT;
-- CreateTable
CREATE TABLE "NotificationTarget" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"target" TEXT NOT NULL,
"secretCipher" TEXT,
"secretHint" TEXT,
"events" "NotificationEvent"[] DEFAULT ARRAY[]::"NotificationEvent"[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastSentAt" TIMESTAMP(3),
"lastOk" BOOLEAN,
"lastMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationTarget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "NotificationTarget_userId_idx" ON "NotificationTarget"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationTarget_userId_name_key" ON "NotificationTarget"("userId", "name");
-- AddForeignKey
ALTER TABLE "NotificationTarget" ADD CONSTRAINT "NotificationTarget_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- AlterEnum
ALTER TYPE "NotificationEvent" ADD VALUE 'PROJECT_STALE';
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "lastStaleCheckAt" TIMESTAMP(3),
ADD COLUMN "staleAfterDays" INTEGER NOT NULL DEFAULT 60;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "notifiedStaleAt" TIMESTAMP(3);
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceIssue" INTEGER,
ADD COLUMN "sourceUrl" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Task_projectId_sourceIssue_key" ON "Task"("projectId", "sourceIssue");
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "ActivityKind" AS ENUM ('PROJECT_CREATED', 'PROJECT_UPDATED', 'STATUS_CHANGED', 'NOTE_ADDED', 'NOTE_UPDATED', 'NOTE_DELETED', 'TASK_ADDED', 'TASK_MOVED', 'TASK_DELETED', 'TASKS_IMPORTED', 'REPO_LINKED', 'REPO_COMMITS', 'SYNC_FAILED');
-- CreateTable
CREATE TABLE "Activity" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT,
"kind" "ActivityKind" NOT NULL,
"summary" TEXT NOT NULL,
"meta" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Activity_projectId_createdAt_idx" ON "Activity"("projectId", "createdAt");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpEnabledAt" TIMESTAMP(3),
ADD COLUMN "totpSecretCipher" TEXT;
-- CreateTable
CREATE TABLE "RecoveryCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecoveryCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PendingLogin" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PendingLogin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RecoveryCode_codeHash_key" ON "RecoveryCode"("codeHash");
-- CreateIndex
CREATE INDEX "RecoveryCode_userId_idx" ON "RecoveryCode"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PendingLogin_tokenHash_key" ON "PendingLogin"("tokenHash");
-- CreateIndex
CREATE INDEX "PendingLogin_userId_idx" ON "PendingLogin"("userId");
-- CreateIndex
CREATE INDEX "PendingLogin_expiresAt_idx" ON "PendingLogin"("expiresAt");
-- AddForeignKey
ALTER TABLE "RecoveryCode" ADD CONSTRAINT "RecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PendingLogin" ADD CONSTRAINT "PendingLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,113 @@
-- Mehrere Repositories je Projekt.
--
-- Die Git-Felder wandern vom Projekt in eine eigene Tabelle. Bestehende
-- Projekte bekommen daraus genau ein Repository, damit nichts verlorengeht;
-- der Snapshot haengt danach am Repository statt am Projekt.
--
-- Von Hand geschrieben: prisma migrate diff wuerde die Spalten am Projekt
-- ersatzlos loeschen und die Snapshots gleich mit.
-- ── 1. Neue Tabelle ─────────────────────────────────────────
CREATE TABLE "Repository" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"label" TEXT,
"repoUrl" TEXT,
"repoOwner" TEXT,
"repoName" TEXT,
"defaultBranch" TEXT,
"connectionId" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"lastSyncedAt" TIMESTAMP(3),
"syncError" TEXT,
"autoSync" BOOLEAN NOT NULL DEFAULT true,
"syncFailureCount" INTEGER NOT NULL DEFAULT 0,
"notifiedSyncError" TEXT,
"notifiedCiState" TEXT,
"notifiedRelease" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Repository_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Repository_projectId_position_idx" ON "Repository"("projectId", "position");
CREATE INDEX "Repository_connectionId_idx" ON "Repository"("connectionId");
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ── 2. Bestand uebernehmen ──────────────────────────────────
-- Nur Projekte, an denen wirklich etwas haengt. Ein Projekt, das bloss eine
-- Verbindung ausgewaehlt hatte, ohne je ein Repo zu nennen, braucht keine
-- leere Zeile.
INSERT INTO "Repository" (
"id", "projectId", "repoUrl", "repoOwner", "repoName", "defaultBranch",
"connectionId", "position", "lastSyncedAt", "syncError", "autoSync",
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
"createdAt", "updatedAt"
)
SELECT
gen_random_uuid()::text, "id", "repoUrl", "repoOwner", "repoName", "defaultBranch",
"connectionId", 0, "lastSyncedAt", "syncError", "autoSync",
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
"createdAt", CURRENT_TIMESTAMP
FROM "Project"
WHERE "repoUrl" IS NOT NULL
OR ("repoOwner" IS NOT NULL AND "repoName" IS NOT NULL);
-- ── 3. Snapshots umhaengen ──────────────────────────────────
ALTER TABLE "RepoSnapshot" ADD COLUMN "repositoryId" TEXT;
UPDATE "RepoSnapshot" AS s
SET "repositoryId" = r."id"
FROM "Repository" AS r
WHERE r."projectId" = s."projectId";
-- Ein Snapshot ohne zugehoeriges Repository kann nur entstanden sein, wenn
-- jemand die Repo-Angaben nachtraeglich geleert hat. Er ist ohnehin nicht
-- mehr zuzuordnen und beim naechsten Abgleich neu geholt.
DELETE FROM "RepoSnapshot" WHERE "repositoryId" IS NULL;
ALTER TABLE "RepoSnapshot" ALTER COLUMN "repositoryId" SET NOT NULL;
ALTER TABLE "RepoSnapshot" DROP CONSTRAINT "RepoSnapshot_projectId_fkey";
DROP INDEX "RepoSnapshot_projectId_key";
ALTER TABLE "RepoSnapshot" DROP COLUMN "projectId";
CREATE UNIQUE INDEX "RepoSnapshot_repositoryId_key" ON "RepoSnapshot"("repositoryId");
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_repositoryId_fkey"
FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ── 4. Herkunft uebernommener Aufgaben ──────────────────────
-- Bisher war die Issue-Nummer je Projekt eindeutig. Mit mehreren Repos
-- reicht das nicht mehr: Frontend und Backend haben beide ein Issue 5.
ALTER TABLE "Task" ADD COLUMN "sourceRepositoryId" TEXT;
UPDATE "Task" AS t
SET "sourceRepositoryId" = r."id"
FROM "Repository" AS r
WHERE r."projectId" = t."projectId" AND t."sourceIssue" IS NOT NULL;
DROP INDEX "Task_projectId_sourceIssue_key";
CREATE UNIQUE INDEX "Task_projectId_sourceRepositoryId_sourceIssue_key"
ON "Task"("projectId", "sourceRepositoryId", "sourceIssue");
-- ── 5. Projekt aufraeumen ───────────────────────────────────
ALTER TABLE "Project" DROP CONSTRAINT "Project_connectionId_fkey";
DROP INDEX "Project_connectionId_idx";
ALTER TABLE "Project"
DROP COLUMN "repoUrl",
DROP COLUMN "repoOwner",
DROP COLUMN "repoName",
DROP COLUMN "defaultBranch",
DROP COLUMN "connectionId",
DROP COLUMN "lastSyncedAt",
DROP COLUMN "syncError",
DROP COLUMN "autoSync",
DROP COLUMN "syncFailureCount",
DROP COLUMN "notifiedSyncError",
DROP COLUMN "notifiedCiState",
DROP COLUMN "notifiedRelease";
@@ -0,0 +1,20 @@
-- Freie Reihenfolge im Projektbrett.
--
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
-- jedem Laden anders ergeben koennte.
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
UPDATE "Project" AS p
SET "position" = nummeriert.rang - 1
FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
) AS rang
FROM "Project"
) AS nummeriert
WHERE nummeriert."id" = p."id";
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}',
ADD COLUMN "commitCalendarAt" TIMESTAMP(3);
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ProjectTemplate" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"summary" TEXT,
"projectDescription" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
"priority" INTEGER NOT NULL DEFAULT 2,
"accent" TEXT NOT NULL DEFAULT 'violet',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"progressFromTasks" BOOLEAN NOT NULL DEFAULT false,
"notes" JSONB NOT NULL DEFAULT '[]',
"tasks" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name");
-- AddForeignKey
ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "Recurrence" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "recurredFrom" TEXT,
ADD COLUMN "recurrence" "Recurrence";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
ADD COLUMN "readmeAt" TIMESTAMP(3),
ADD COLUMN "readmeBaseUrl" TEXT,
ADD COLUMN "readmePath" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Repository" ADD COLUMN "webhookLastAt" TIMESTAMP(3),
ADD COLUMN "webhookSecret" TEXT;
@@ -0,0 +1,20 @@
-- Volltextsuche ueber Aufgaben.
--
-- Wie Note_fulltext_idx ein Ausdrucksindex: Prisma kann so etwas im Schema
-- nicht abbilden und kennt ihn deshalb nicht. Nachgeprueft mit
-- "prisma migrate diff" gegen das Schema - es kommt "empty migration"
-- heraus, der Index wird also weder angelegt noch geloescht. Trotzdem beim
-- Erzeugen kuenftiger Migrationen kurz hinsehen.
--
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
-- Postgres berechnet to_tsvector dann je Zeile.
--
-- Der Ausdruck muss Zeichen fuer Zeichen dem in src/app/api/search/route.ts
-- entsprechen, sonst greift der Index nicht. Die zweistellige Form von
-- to_tsvector mit fester Konfiguration ist IMMUTABLE und damit indizierbar.
--
-- Anders als bei der Notiz ist auch die Beschreibung optional, deshalb steht
-- coalesce um beide Spalten.
CREATE INDEX "Task_fulltext_idx" ON "Task"
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || coalesce("description", '')));
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+649
View File
@@ -0,0 +1,649 @@
// ─────────────────────────────────────────────────────────────
// Nebula – Datenmodell
// Datenbank: PostgreSQL (lokal oder via docker compose)
// ─────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
// linux-musl-openssl-3.0.x wird fuer das Alpine-Docker-Image benoetigt.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Lebenszyklus eines Projekts. IDEA ist der Startzustand fuer
/// frisch notierte Einfaelle, ARCHIVED blendet Projekte aus dem
/// Standard-Board aus, ohne sie zu loeschen.
enum ProjectStatus {
IDEA
OPEN
PLANNING
IN_DEVELOPMENT
DONE
ARCHIVED
}
/// Unterstuetzte Git-Systeme.
enum GitProvider {
GITHUB
GITLAB
GITEA
}
/// Zustellwege fuer Benachrichtigungen.
enum NotificationChannel {
WEBHOOK
NTFY
EMAIL
}
/// Anlaesse, ueber die benachrichtigt werden kann.
enum NotificationEvent {
/// Ein Abgleich mit dem Git-System ist fehlgeschlagen.
SYNC_FAILED
/// Der letzte CI-Lauf auf dem Default-Branch ist rot.
CI_FAILED
/// Im Repository ist ein neuer Tag oder ein neues Release aufgetaucht.
NEW_RELEASE
/// Seit laengerem kein Commit mehr – der sanfte Stups.
PROJECT_STALE
}
/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der
/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung
/// mitschreiben.
enum ActivityKind {
PROJECT_CREATED
PROJECT_UPDATED
STATUS_CHANGED
NOTE_ADDED
NOTE_UPDATED
NOTE_DELETED
TASK_ADDED
TASK_MOVED
TASK_DELETED
TASKS_IMPORTED
REPO_LINKED
REPO_COMMITS
SYNC_FAILED
}
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
enum Recurrence {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole {
ADMIN
USER
}
/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste.
/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten.
enum AppMode {
SINGLE_USER
MULTI_USER
}
/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch
/// ausschliesslich per Passkey nutzbar sein darf.
model User {
id String @id @default(cuid())
/// Immer kleingeschrieben gespeichert, damit die Anmeldung
/// gross-/kleinschreibungsunabhaengig funktioniert.
username String @unique
displayName String?
email String? @unique
/// scrypt-Hash im Format scrypt$N$r$p$<salt>$<hash>; null = nur Passkey.
passwordHash String?
role UserRole @default(USER)
isActive Boolean @default(true)
lastLoginAt DateTime?
/// Zaehler und Sperre gegen Passwortraten.
failedLoginCount Int @default(0)
lockedUntil DateTime?
/// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
/// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt
/// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst
/// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String?
totpEnabledAt DateTime?
/// Zuletzt eingeloestes Zeitfenster. RFC 6238 verlangt, denselben Code
/// kein zweites Mal anzunehmen – ohne diesen Merker liesse sich ein
/// abgefangener Code die restliche Gueltigkeit ueber wiederverwenden.
totpLastCounter Int?
sessions Session[]
credentials Credential[]
projects Project[]
connections GitConnection[]
notificationTargets NotificationTarget[]
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
templates ProjectTemplate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([role])
}
/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des
/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt.
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
lastSeenAt DateTime @default(now())
userAgent String?
ip String?
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Ein registrierter Passkey (WebAuthn-Credential).
model Credential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// base64url-kodierte Credential-ID des Authenticators.
credentialId String @unique
publicKey Bytes
counter Int @default(0)
transports String[] @default([])
deviceType String?
backedUp Boolean @default(false)
/// Frei waehlbarer Name, z. B. "MacBook Touch ID".
name String?
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist.
/// Jeder Code gilt genau einmal.
model RecoveryCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die
/// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos
/// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt
/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann
/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt.
model PendingLogin {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier.
tokenHash String @unique
/// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die
/// sechs Stellen nicht in Ruhe durchprobiert werden koennen.
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
/// kein zweites Mal verwendet werden kann.
model WebAuthnChallenge {
id String @id @default(cuid())
challenge String @unique
/// null bei der Anmeldung ueber auffindbare Passkeys.
userId String?
/// "registration" oder "authentication"
purpose String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([expiresAt])
}
/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton".
model AppSettings {
id String @id @default("singleton")
mode AppMode @default(SINGLE_USER)
/// Nur im Mehrbenutzerbetrieb wirksam.
allowRegistration Boolean @default(false)
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
rpName String @default("Nebula")
// ── Automatischer Abgleich mit den Git-Systemen ────────────
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
/// das soll eine bewusste Entscheidung sein.
autoSyncEnabled Boolean @default(false)
/// Abstand zwischen zwei Durchlaeufen in Minuten.
autoSyncIntervalMinutes Int @default(60)
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
/// noch etwas.
autoSyncActiveOnly Boolean @default(true)
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
/// einen Schlag in ein Rate-Limit laeuft.
autoSyncBatchSize Int @default(25)
lastAutoSyncAt DateTime?
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json?
// ── Stillstand ─────────────────────────────────────────────
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
staleAfterDays Int @default(60)
/// Die Pruefung laeuft hoechstens einmal am Tag.
lastStaleCheckAt DateTime?
updatedAt DateTime @updatedAt
}
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
model GitConnection {
id String @id @default(cuid())
name String
provider GitProvider
/// Basis-URL der Instanz, z. B. https://github.com, https://gitlab.com,
/// https://git.meine-domain.de
baseUrl String
/// Optionaler Benutzername – rein informativ fuer die Anzeige.
username String?
/// Personal Access Token, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
tokenCipher String?
/// Gekuerzte Darstellung des Tokens fuer die UI, z. B. "ghp_…4f2a".
tokenHint String?
lastCheckedAt DateTime?
lastCheckOk Boolean?
lastCheckMessage String?
/// Besitzer. Nullable, damit Bestandsdaten aus der Zeit vor der
/// Benutzerverwaltung erhalten bleiben; beim Einrichten des ersten
/// Kontos werden sie diesem zugeordnet.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
repositories Repository[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, name])
@@index([provider])
@@index([ownerId])
}
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
model Project {
id String @id @default(cuid())
name String
/// Pro Besitzer eindeutig – siehe @@unique unten.
slug String
/// Einzeiler fuer die Karte im Dashboard.
summary String?
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
description String?
status ProjectStatus @default(IDEA)
/// 1 = niedrig, 2 = normal, 3 = hoch, 4 = kritisch
priority Int @default(2)
/// Fortschritt in Prozent (0–100)
progress Int @default(0)
/// Akzentfarbe der Karte: violet | cyan | emerald | amber | rose | blue
accent String @default("violet")
tags String[] @default([])
favorite Boolean @default(false)
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
/// uebrigen Ansichten folgen der gewaehlten Sortierung.
position Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
/// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht
/// ueber ein einzelnes Repository.
notifiedStaleAt DateTime?
notes Note[]
tasks Task[]
activities Activity[]
repositories Repository[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, slug])
@@index([status])
@@index([updatedAt])
@@index([ownerId])
@@index([ownerId, status, position])
}
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
/// Frontend und Backend etwa –, gehoert aber in eine Karte.
///
/// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen
/// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist.
model Repository {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name
/// steht fuer sich.
label String?
repoUrl String?
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
repoOwner String?
repoName String?
defaultBranch String?
connectionId String?
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
/// Reihenfolge in der Anzeige.
position Int @default(0)
lastSyncedAt DateTime?
syncError String?
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
/// einem Repo, dessen Token staendig Fehler wirft.
autoSync Boolean @default(true)
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
syncFailureCount Int @default(0)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst
/// verdeckt ein rotes Frontend die rote Pipeline des Backends.
notifiedSyncError String?
notifiedCiState String?
notifiedRelease String?
/// Gemeinsames Geheimnis fuer eingehende Webhooks, verschluesselt wie die
/// Zugangstoken. Je Repository, weil der Haken im Git-System dort
/// eingetragen wird und sich einzeln zuruecknehmen laesst.
/// NULL heisst: fuer dieses Repository ist kein Webhook eingerichtet, und
/// eingehende Aufrufe werden abgewiesen.
webhookSecret String?
/// Wann zuletzt ein gueltiger Webhook ankam – die einzige Rueckmeldung
/// darueber, ob der Haken im Git-System wirklich funktioniert.
webhookLastAt DateTime?
snapshot RepoSnapshot?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, position])
@@index([connectionId])
}
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
model NotificationTarget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
channel NotificationChannel
/// Webhook-URL, ntfy-Themen-URL oder E-Mail-Adresse.
target String
/// Optionales Geheimnis, AES-256-GCM verschluesselt: Bearer-Token fuer
/// ntfy oder ein Signaturschluessel fuer den Webhook.
secretCipher String?
secretHint String?
/// Worueber dieses Ziel benachrichtigt werden will.
events NotificationEvent[] @default([])
isActive Boolean @default(true)
lastSentAt DateTime?
lastOk Boolean?
lastMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
model Task {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
/// Optionale Notiz zur Aufgabe, Markdown wird dargestellt.
description String?
status TaskStatus @default(TODO)
/// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn
/// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt.
/// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert.
position Int @default(0)
dueDate DateTime?
labels String[] @default([])
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime?
/// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die
/// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte
/// bleibt stehen – sonst ginge die Historie verloren.
recurrence Recurrence?
/// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit;
/// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die
/// Nachfolgerin nicht mitnimmt.
recurredFrom String?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int?
sourceUrl String?
/// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und
/// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
sourceRepositoryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne
/// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden.
@@unique([projectId, sourceRepositoryId, sourceIssue])
@@index([projectId, status, position])
}
/// Eine Vorlage fuer neue Projekte: Stammdaten, Tags, Notizen und Aufgaben.
///
/// Bewusst benutzereigen und nicht fest eingebaut. Welche Tags und welche
/// Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet – eine
/// mitgelieferte Liste waere geraten.
model ProjectTemplate {
id String @id @default(cuid())
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
name String
/// Wozu die Vorlage gut ist – erscheint in der Auswahl.
description String?
// ── Was die Vorlage in das neue Projekt schreibt ───────────
summary String?
projectDescription String?
status ProjectStatus @default(IDEA)
priority Int @default(2)
accent String @default("violet")
tags String[] @default([])
progressFromTasks Boolean @default(false)
/// [{ title, content, pinned }]
notes Json @default("[]")
/// [{ title, description, status, labels }]
tasks Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, name])
@@index([ownerId])
}
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim
/// automatischen Abgleich.
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
kind ActivityKind
/// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben
/// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es
/// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz.
summary String
/// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht
/// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll.
meta Json?
createdAt DateTime @default(now())
/// Absteigend gelesen, immer je Projekt.
@@index([projectId, createdAt])
}
/// Freie Notizen zu einem Projekt.
model Note {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String?
content String
pinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId])
@@index([pinned])
}
/// Zwischengespeicherter Stand eines Repositories. Wird beim Sync
/// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
model RepoSnapshot {
id String @id @default(cuid())
repositoryId String @unique
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
fullName String?
description String?
defaultBranch String?
htmlUrl String?
visibility String?
stars Int @default(0)
forks Int @default(0)
openIssues Int @default(0)
openPullRequests Int @default(0)
sizeKb Int @default(0)
lastCommitSha String?
lastCommitMessage String?
lastCommitAuthor String?
lastCommitAt DateTime?
latestTag String?
latestRelease String?
latestReleaseAt DateTime?
branchCount Int @default(0)
tagCount Int @default(0)
/// Rohdaten der Listen – bewusst als JSON, damit providerspezifische
/// Zusatzfelder nicht verloren gehen.
branches Json @default("[]")
tags Json @default("[]")
commits Json @default("[]")
releases Json @default("[]")
languages Json @default("{}")
pullRequests Json @default("[]")
issues Json @default("[]")
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
/// Commits je Tag ueber bis zu ein Jahr – die Heatmap.
/// { from, to, days: { "2026-07-26": 3 }, source, truncated }
/// Getrennt vom uebrigen Stand, weil das Holen zusaetzliche Aufrufe kostet
/// und deshalb hoechstens einmal am Tag geschieht.
commitCalendar Json @default("{}")
commitCalendarAt DateTime?
/// Das README des Repositories, roh als Markdown. Wie der Kalender
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
/// Abgleich laeuft im Zweifel stuendlich.
readme String?
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
readmePath String?
/// Wogegen relative Links im README aufzuloesen sind.
readmeBaseUrl String?
readmeAt DateTime?
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
ciName String?
ciBranch String?
ciUrl String?
ciFinishedAt DateTime?
pushedAt DateTime?
fetchedAt DateTime @default(now())
}
+121
View File
@@ -0,0 +1,121 @@
import { PrismaClient, ProjectStatus } from "@prisma/client";
const prisma = new PrismaClient();
interface SampleNote {
title?: string | null;
content: string;
pinned?: boolean;
}
interface SampleProject {
name: string;
slug: string;
summary: string;
description: string;
status: ProjectStatus;
priority: number;
progress: number;
accent: string;
tags: string[];
favorite: boolean;
notes: SampleNote[];
}
/**
* Legt ein paar Beispielprojekte an, damit das Dashboard nach dem ersten
* Start nicht leer ist. Idempotent: bereits vorhandene Slugs werden
* uebersprungen.
*/
async function main() {
const samples: SampleProject[] = [
{
name: "Nebula",
slug: "nebula",
summary: "Dieses Dashboard – Uebersicht ueber alle Vibe-Coding Projekte.",
description:
"Meta: Nebula verwaltet sich selbst. Notizen, Status und Git-Infos an einem Ort.",
status: ProjectStatus.IN_DEVELOPMENT,
priority: 3,
progress: 65,
accent: "violet",
tags: ["next.js", "prisma", "dashboard"],
favorite: true,
notes: [
{
title: "Naechste Schritte",
content:
"- Kanban-Ansicht ergaenzen\n- Webhook-Endpoint fuer Push-Events\n- Export als Markdown",
pinned: true,
},
],
},
{
name: "Synthwave CLI",
slug: "synthwave-cli",
summary: "Terminal-Tool, das Projektgeruest inkl. CI in Sekunden erzeugt.",
description:
"Idee: ein `npx synthwave init` das Repo, Lint, Tests, Container und Pipeline vorkonfiguriert.",
status: ProjectStatus.PLANNING,
priority: 2,
progress: 15,
accent: "cyan",
tags: ["cli", "typescript", "dx"],
favorite: false,
notes: [
{
title: null,
content: "Konkurrenz pruefen: create-t3-app, degit, plop.",
pinned: false,
},
],
},
{
name: "Pixelgarten",
slug: "pixelgarten",
summary: "Browser-Spiel: ein Garten, der aus Commit-Aktivitaet waechst.",
description:
"Rohe Idee. Jeder Commit pflanzt eine Pflanze, Streaks lassen sie bluehen.",
status: ProjectStatus.IDEA,
priority: 1,
progress: 0,
accent: "emerald",
tags: ["spiel", "canvas", "fun"],
favorite: false,
notes: [],
},
];
// Der Slug ist seit der Benutzerverwaltung nur je Besitzer eindeutig.
// Die Seed-Projekte entstehen ohne Besitzer und werden beim Einrichten
// des ersten Kontos uebernommen.
for (const sample of samples) {
const existing = await prisma.project.findFirst({
where: { slug: sample.slug, ownerId: null },
});
if (existing) {
console.log(` · uebersprungen (existiert): ${sample.slug}`);
continue;
}
const { notes, ...project } = sample;
await prisma.project.create({
data: {
...project,
notes: notes.length ? { create: notes } : undefined,
},
});
console.log(` ✓ angelegt: ${sample.slug}`);
}
}
main()
.then(async () => {
console.log("Seed abgeschlossen.");
await prisma.$disconnect();
})
.catch(async (error) => {
console.error("Seed fehlgeschlagen:", error);
await prisma.$disconnect();
process.exit(1);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+64
View File
@@ -0,0 +1,64 @@
<#
.SYNOPSIS
Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
.DESCRIPTION
Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
Datenbanksicherung: Schema, Daten, Sequenzen, Indizes. Genau das, was
man zum Wiederherstellen einer kaputten Instanz braucht.
Laeuft die Datenbank im mitgelieferten Container, wird pg_dump dort
ausgefuehrt - auf dem Rechner selbst muss dann nichts installiert sein.
.PARAMETER OutDir
Zielverzeichnis. Vorgabe: .\backups
.PARAMETER Container
Name des Postgres-Containers. Vorgabe: nebula-db
.EXAMPLE
.\scripts\backup.ps1
.\scripts\backup.ps1 -OutDir D:\Sicherungen
#>
[CmdletBinding()]
param(
[string]$OutDir = (Join-Path $PSScriptRoot "..\backups"),
[string]$Container = "nebula-db"
)
$ErrorActionPreference = "Stop"
# .env einlesen, damit Benutzer und Datenbankname nicht doppelt gepflegt werden.
$envFile = Join-Path $PSScriptRoot "..\.env"
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
$settings[$Matches[1]] = $Matches[2].Trim()
}
}
}
$user = $settings["POSTGRES_USER"]
$database = $settings["POSTGRES_DB"]
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
$stamp = Get-Date -Format "yyyy-MM-dd-HHmmss"
$target = Join-Path $OutDir "nebula-$stamp.sql"
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
if ($running -notcontains $Container) {
Write-Error "Container '$Container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren."
}
Write-Host "Sichere Datenbank '$database' aus Container '$Container'..."
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
docker exec $Container pg_dump -U $user -d $database --clean --if-exists | Out-File -Encoding utf8 $target
if ($LASTEXITCODE -ne 0) { Write-Error "pg_dump ist fehlgeschlagen." }
$size = [math]::Round((Get-Item $target).Length / 1KB, 1)
Write-Host "Fertig: $target ($size KB)" -ForegroundColor Green
Write-Host "Wiederherstellen mit: .\scripts\restore.ps1 -File `"$target`""
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
#
# Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
# Datenbanksicherung: Schema, Daten, Sequenzen, Indizes.
#
# Aufruf: ./scripts/backup.sh [Zielverzeichnis]
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
out_dir="${1:-$root/backups}"
container="${NEBULA_DB_CONTAINER:-nebula-db}"
user="nebula"
database="nebula"
if [ -f "$root/.env" ]; then
# Nur die beiden benoetigten Werte lesen, ohne die Datei auszufuehren.
value_of() {
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
"$root/.env" | tail -n 1
}
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
fi
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
echo "Container '$container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren." >&2
exit 1
fi
mkdir -p "$out_dir"
stamp="$(date +%Y-%m-%d-%H%M%S)"
target="$out_dir/nebula-$stamp.sql"
echo "Sichere Datenbank '$database' aus Container '$container'..."
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
docker exec "$container" pg_dump -U "$user" -d "$database" --clean --if-exists > "$target"
size="$(du -h "$target" | cut -f1)"
echo "Fertig: $target ($size)"
echo "Wiederherstellen mit: ./scripts/restore.sh \"$target\""
+62
View File
@@ -0,0 +1,62 @@
<#
.SYNOPSIS
Spielt eine mit backup.ps1 erzeugte Sicherung zurueck.
.DESCRIPTION
ACHTUNG: Der aktuelle Inhalt der Datenbank wird dabei ersetzt. Das Skript
fragt vorher nach, es sei denn, -Force ist gesetzt.
.PARAMETER Database
Zieldatenbank. Vorgabe: der Wert aus der .env.
Tipp: Eine Sicherung laesst sich pruefen, ohne die echte Datenbank
anzufassen - in eine leere Testdatenbank einspielen und nachsehen:
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
.\scripts\restore.ps1 -File .\backups\... -Database nebula_test -Force
.EXAMPLE
.\scripts\restore.ps1 -File .\backups\nebula-2026-07-26-101500.sql
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$File,
[string]$Database,
[string]$Container = "nebula-db",
[switch]$Force
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $File)) { Write-Error "Datei nicht gefunden: $File" }
$envFile = Join-Path $PSScriptRoot "..\.env"
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
$settings[$Matches[1]] = $Matches[2].Trim()
}
}
}
$user = $settings["POSTGRES_USER"]
$database = if ($Database) { $Database } else { $settings["POSTGRES_DB"] }
if (-not $Force) {
Write-Host "Der gesamte Inhalt der Datenbank '$database' wird ersetzt." -ForegroundColor Yellow
$answer = Read-Host "Wirklich fortfahren? (ja/nein)"
if ($answer -ne "ja") { Write-Host "Abgebrochen."; exit 0 }
}
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
if ($running -notcontains $Container) {
Write-Error "Container '$Container' laeuft nicht."
}
Write-Host "Spiele '$File' ein..."
Get-Content $File -Raw | docker exec -i $Container psql -U $user -d $database -v ON_ERROR_STOP=1
if ($LASTEXITCODE -ne 0) { Write-Error "Das Einspielen ist fehlgeschlagen." }
Write-Host "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut." -ForegroundColor Green
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Spielt eine mit backup.sh erzeugte Sicherung zurueck.
#
# ACHTUNG: Der aktuelle Inhalt der Datenbank wird ersetzt.
#
# Aufruf: ./scripts/restore.sh <datei.sql> [--force]
#
# Zieldatenbank ueberschreiben mit NEBULA_DB_NAME. Damit laesst sich eine
# Sicherung pruefen, ohne die echte Datenbank anzufassen:
#
# docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
# NEBULA_DB_NAME=nebula_test ./scripts/restore.sh backups/... --force
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
file="${1:-}"
force="${2:-}"
container="${NEBULA_DB_CONTAINER:-nebula-db}"
if [ -z "$file" ]; then
echo "Aufruf: ./scripts/restore.sh <datei.sql> [--force]" >&2
exit 1
fi
if [ ! -f "$file" ]; then
echo "Datei nicht gefunden: $file" >&2
exit 1
fi
user="nebula"
database="nebula"
if [ -f "$root/.env" ]; then
value_of() {
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
"$root/.env" | tail -n 1
}
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
fi
database="${NEBULA_DB_NAME:-$database}"
if [ "$force" != "--force" ]; then
printf "Der gesamte Inhalt der Datenbank '%s' wird ersetzt.\nWirklich fortfahren? (ja/nein) " "$database"
read -r answer
[ "$answer" = "ja" ] || { echo "Abgebrochen."; exit 0; }
fi
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
echo "Container '$container' laeuft nicht." >&2
exit 1
fi
echo "Spiele '$file' ein..."
docker exec -i "$container" psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 < "$file"
echo "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut."
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Nebula – Ersteinrichtung.
*
* Legt eine .env auf Basis von .env.example an und erzeugt dabei einen
* zufaelligen APP_SECRET. Vorhandene .env-Dateien werden nicht angefasst.
*
* node scripts/setup.mjs bzw. npm run setup
*/
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = join(root, ".env");
const examplePath = join(root, ".env.example");
const c = {
reset: "",
dim: "",
green: "",
yellow: "",
cyan: "",
};
function log(message) {
console.log(message);
}
if (!existsSync(examplePath)) {
console.error(`${c.yellow}.env.example nicht gefunden – abgebrochen.${c.reset}`);
process.exit(1);
}
if (existsSync(envPath)) {
log(`${c.yellow}·${c.reset} .env existiert bereits – sie wird nicht ueberschrieben.`);
} else {
const secret = randomBytes(32).toString("base64");
const content = readFileSync(examplePath, "utf8").replace(
/^APP_SECRET=.*$/m,
`APP_SECRET="${secret}"`,
);
writeFileSync(envPath, content, "utf8");
log(`${c.green}✓${c.reset} .env angelegt (APP_SECRET wurde zufaellig erzeugt).`);
}
log("");
log(`${c.cyan}Naechste Schritte${c.reset}`);
log(`${c.dim}─────────────────────────────────────────────${c.reset}`);
log("Standalone (App lokal, Datenbank im Container):");
log(` ${c.dim}1.${c.reset} docker compose up -d db`);
log(` ${c.dim}2.${c.reset} npm install`);
log(` ${c.dim}3.${c.reset} npm run db:migrate`);
log(` ${c.dim}4.${c.reset} npm run db:seed ${c.dim}(optional, Beispieldaten)${c.reset}`);
log(` ${c.dim}5.${c.reset} npm run dev ${c.dim}→ http://localhost:3000${c.reset}`);
log("");
log("Alles in Docker:");
log(` ${c.dim}1.${c.reset} docker compose --profile full up -d --build`);
log(` ${c.dim}2.${c.reset} ${c.dim}→ http://localhost:3000${c.reset}`);
log("");
log("Eigene, bereits laufende PostgreSQL:");
log(` DATABASE_URL in der .env anpassen, dann npm run db:migrate`);
log("");
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Legt die .env an und wuerfelt ein APP_SECRET.
#
# Dasselbe wie scripts/setup.mjs, aber ohne Node: auf einem reinen
# Docker-Host ist keins installiert, und nur zum Erzeugen einer Textdatei
# eines zu verlangen waere albern. Braucht nichts ausser einer POSIX-Shell.
#
# Aufruf: ./scripts/setup.sh
# ./scripts/setup.sh --force (vorhandene .env ueberschreiben)
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
env_datei="$root/.env"
vorlage="$root/.env.example"
force=0
[ "${1:-}" = "--force" ] && force=1
if [ -t 1 ]; then
gruen=$'\033[32m'; gelb=$'\033[33m'; cyan=$'\033[36m'; dim=$'\033[2m'; aus=$'\033[0m'
else
gruen=""; gelb=""; cyan=""; dim=""; aus=""
fi
[ -f "$vorlage" ] || { echo "${gelb}.env.example nicht gefunden – abgebrochen.${aus}" >&2; exit 1; }
# ── Zufall ───────────────────────────────────────────────────
# openssl ist auf den meisten Systemen da, aber nicht auf allen. /dev/urandom
# gibt es ueberall, wo diese Shell laeuft – und base64 ebenso.
zufall() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 32
else
head -c 32 /dev/urandom | base64
fi | tr -d '\n'
}
if [ -f "$env_datei" ] && [ "$force" -eq 0 ]; then
echo "${gelb}·${aus} .env existiert bereits – sie wird nicht ueberschrieben."
echo " ${dim}Mit --force neu anlegen (die alte wird gesichert).${aus}"
else
if [ -f "$env_datei" ]; then
sicherung="$env_datei.$(date +%Y%m%d%H%M%S).bak"
cp "$env_datei" "$sicherung"
echo "${gelb}·${aus} Alte .env gesichert: $(basename "$sicherung")"
fi
secret="$(zufall)"
# Das Geheimnis kann / und + enthalten; als sed-Ersetzung waeren das
# Sonderzeichen. Deshalb zeilenweise ersetzen statt mit einem Ausdruck.
: > "$env_datei"
while IFS= read -r zeile || [ -n "$zeile" ]; do
case "$zeile" in
APP_SECRET=*) printf 'APP_SECRET="%s"\n' "$secret" >> "$env_datei" ;;
*) printf '%s\n' "$zeile" >> "$env_datei" ;;
esac
done < "$vorlage"
chmod 600 "$env_datei"
echo "${gruen}✓${aus} .env angelegt, APP_SECRET zufaellig erzeugt, Rechte auf 600."
fi
echo
echo "${cyan}Noch anzupassen${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " APP_URL die Adresse, unter der du Nebula aufrufst"
echo " ${dim}bestimmt die WebAuthn-rpID – passt sie nicht, scheitern Passkeys${aus}"
echo " POSTGRES_PASSWORD die Voreinstellung ist fuer Entwicklung gedacht"
echo " APP_PORT auf einem Server: 127.0.0.1:3000 hinter den Reverse Proxy"
echo
echo "${cyan}Danach${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " docker compose --profile full up -d --build"
echo " ${dim}Der Container spielt die Migrationen beim Start selbst ein.${aus}"
echo
echo " Ausfuehrlich: DEPLOYMENT.md"
echo
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# Nebula auf einem Server aktualisieren.
#
# Holt den neuen Stand, sichert die Datenbank, baut das Abbild, spielt die
# Migrationen ein und startet neu. Bricht ab, sobald ein Schritt scheitert –
# und sagt dann, wie zurueckzukommen ist.
#
# Aufruf: ./scripts/update.sh
# ./scripts/update.sh --no-pull (nur neu bauen und starten)
# ./scripts/update.sh --ref v1.4.0 (auf einen Stand festlegen)
# ./scripts/update.sh --yes (ohne Rueckfrage)
#
# Rueckgabe 0 heisst: die neue Fassung laeuft und antwortet gesund.
# ─────────────────────────────────────────────────────────────
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
pull=1
ref=""
zusage=0
while [ $# -gt 0 ]; do
case "$1" in
--no-pull) pull=0 ;;
--ref) ref="${2:-}"; shift ;;
--yes|-y) zusage=1 ;;
-h|--help) sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
shift
done
# ── Farben, aber nur wenn ein Terminal zusieht ───────────────
if [ -t 1 ]; then
rot=$'\033[31m'; gruen=$'\033[32m'; gelb=$'\033[33m'; fett=$'\033[1m'; aus=$'\033[0m'
else
rot=""; gruen=""; gelb=""; fett=""; aus=""
fi
# Die Version aus der package.json lesen – ohne Node. Auf einem reinen
# Docker-Host ist keins installiert, und dieses Skript soll dort laufen.
paket_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$root/package.json" \
| head -n 1
}
schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; }
ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; }
warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; }
abbruch() { printf '\n%s✗ %s%s\n' "$rot" "$1" "$aus" >&2; exit 1; }
# ── Voraussetzungen ──────────────────────────────────────────
schritt "Voraussetzungen"
command -v docker >/dev/null || abbruch "docker nicht gefunden."
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden."
[ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst './scripts/setup.sh' ausfuehren."
[ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root."
ok "docker, compose und .env vorhanden"
# APP_SECRET muss gesetzt sein – sonst liegen die Git-Tokens faktisch offen.
if grep -qE '^\s*APP_SECRET\s*=\s*("?)(nebula-dev-secret[^"]*|)\1\s*$' "$root/.env"; then
abbruch "APP_SECRET ist leer oder noch der Entwicklungswert. Bitte in der .env setzen."
fi
ok "APP_SECRET gesetzt"
vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")"
version_vorher="$(paket_version)"
printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher"
# ── Neuen Stand holen ────────────────────────────────────────
if [ "$pull" -eq 1 ]; then
schritt "Neuen Stand holen"
schmutz="$(git status --porcelain)"
if [ -n "$schmutz" ]; then
# Nicht nur meckern, sondern zeigen, worum es geht – sonst muss man
# raten, was man da eigentlich verwerfen soll.
printf '\n%sGeaendert gegenueber dem Stand im Git:%s\n' "$fett" "$aus"
printf '%s\n' "$schmutz" | sed 's/^/ /'
printf '\n Verwerfen: git checkout -- .\n'
printf ' Behalten: git stash\n'
abbruch "Das Arbeitsverzeichnis ist nicht sauber."
fi
git fetch --all --tags --quiet
if [ -n "$ref" ]; then
git checkout --quiet "$ref"
ok "Auf $ref gewechselt"
else
zweig="$(git rev-parse --abbrev-ref HEAD)"
git pull --ff-only --quiet origin "$zweig"
ok "$zweig aktualisiert"
fi
nachher="$(git rev-parse --short HEAD)"
version_nachher="$(paket_version)"
if [ "$vorher" = "$nachher" ]; then
warnung "Kein neuer Stand – es wird trotzdem neu gebaut."
else
printf ' Neu: v%s (%s)\n' "$version_nachher" "$nachher"
printf '\n Was dazukam:\n'
git --no-pager log --oneline --no-decorate "$vorher..$nachher" 2>/dev/null | sed 's/^/ /' || true
fi
else
warnung "Ohne --pull: der vorhandene Stand wird neu gebaut."
fi
# ── Rueckfrage ───────────────────────────────────────────────
if [ "$zusage" -eq 0 ] && [ -t 0 ]; then
printf '\n%sWeiter? Die Datenbank wird vorher gesichert. [j/N] %s' "$fett" "$aus"
read -r antwort
case "$antwort" in [jJyY]*) ;; *) abbruch "Abgebrochen." ;; esac
fi
# ── Datenbank sichern ────────────────────────────────────────
schritt "Datenbank sichern"
# Die Datenbank muss laufen, sonst gibt es nichts zu sichern.
docker compose up -d db >/dev/null
sicherung=""
if [ -x "$root/scripts/backup.sh" ]; then
ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe"
# backup.sh setzt den Pfad in Anfuehrungszeichen – die gehoeren nicht in
# den Namen, sonst laesst sich der Vorschlag am Ende nicht kopieren.
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ "]*backups/[^ "]*\.sql[^ "]*' | tail -n 1 || true)"
ok "Gesichert${sicherung:+: $sicherung}"
else
warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren."
fi
# ── Bauen ────────────────────────────────────────────────────
schritt "Abbild bauen"
# Erst bauen, dann umschalten: ein Fehlschlag laesst die alte Fassung laufen.
docker compose --profile full build app || abbruch "Der Bau ist gescheitert. Die laufende Fassung ist unveraendert."
ok "Abbild gebaut"
# ── Starten ──────────────────────────────────────────────────
schritt "Starten"
# Die Migrationen laufen im entrypoint.sh, bevor der Server hochkommt.
docker compose --profile full up -d || abbruch "Der Start ist gescheitert."
ok "Container gestartet"
# ── Gesundheitspruefung ──────────────────────────────────────
schritt "Gesundheitspruefung"
# Gefragt wird der Container selbst, nicht der Host. Das Abbild bringt einen
# HEALTHCHECK mit, und so ist es einerlei, ob APP_PORT nur eine Nummer ist
# oder wie auf einem Server "127.0.0.1:3000" – und curl braucht es auch nicht.
gesund=0
for _ in $(seq 1 40); do
zustand="$(docker inspect --format '{{.State.Health.Status}}' nebula-app 2>/dev/null || echo "weg")"
case "$zustand" in
healthy) gesund=1; break ;;
unhealthy) break ;;
esac
sleep 3
done
if [ "$gesund" -ne 1 ]; then
printf '\n%sLetzte Zeilen aus dem Log:%s\n' "$fett" "$aus"
docker compose --profile full logs --tail 40 app || true
printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus"
printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher"
[ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung"
abbruch "Der Container meldet sich nach zwei Minuten nicht gesund (Zustand: ${zustand})."
fi
ok "Container meldet sich gesund"
# Die Anzeige der Adresse. Nicht die .env raten, sondern Docker fragen: mit
# docker-compose.server.yml veroeffentlicht der Container gar keinen Port und
# haengt nur im Netz des Reverse Proxy. Dann gibt "port" nichts zurueck.
adresse="$(docker compose --profile full port app 3000 2>/dev/null | tail -n 1 || true)"
if [ -n "$adresse" ]; then
port="${adresse##*:}"
if command -v curl >/dev/null 2>&1; then
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health" 2>/dev/null || true)"
[ -n "$antwort_json" ] && ok "Antwort: $antwort_json"
fi
wo="$adresse"
else
wo="im Docker-Netz (kein Host-Port veroeffentlicht)"
fi
# ── Fertig ───────────────────────────────────────────────────
version_jetzt="$(paket_version)"
printf '\n%s%s✓ Nebula v%s laeuft auf %s%s\n' "$gruen" "$fett" "$version_jetzt" "$wo" "$aus"
[ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung"
printf ' Log mitlesen: docker compose --profile full logs -f app\n\n'
+101
View File
@@ -0,0 +1,101 @@
import type { Metadata } from "next";
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
import {
NotificationSection,
type NotificationTargetEntry,
} from "@/components/account/NotificationSection";
import { emailAvailable } from "@/lib/notify";
import { requireUserPage } from "@/lib/auth/guard";
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Mein Konto" };
}
export default async function AccountPage() {
const user = await requireUserPage();
const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([
prisma.credential.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "asc" },
}),
prisma.session.findMany({
where: { userId: user.id, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ip: true,
createdAt: true,
lastSeenAt: true,
},
orderBy: { lastSeenAt: "desc" },
}),
prisma.notificationTarget.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
channel: true,
target: true,
secretHint: true,
events: true,
isActive: true,
lastSentAt: true,
lastOk: true,
lastMessage: true,
},
orderBy: { name: "asc" },
}),
prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { totpEnabledAt: true },
}),
prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }),
]);
const data: AccountData = {
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
hasPassword: user.hasPassword,
passkeys,
sessions,
passkeysAvailable: WEBAUTHN_AVAILABLE,
totp: {
enabled: Boolean(konto.totpEnabledAt),
enabledAt: konto.totpEnabledAt,
recoveryCodesLeft: offeneCodes,
},
};
return (
<div className="mx-auto max-w-3xl space-y-6 pt-4">
<header className="fade-up">
<h1 className="text-2xl font-semibold tracking-tight text-white">Mein Konto</h1>
<p className="mt-1 text-sm text-slate-400">
Profil, Anmeldemittel und aktive Sitzungen.
</p>
</header>
<AccountManager data={data} />
<NotificationSection
targets={targets as unknown as NotificationTargetEntry[]}
emailAvailable={emailAvailable()}
/>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import {
AdminManager,
type AdminData,
type AutoSyncReport,
} from "@/components/admin/AdminManager";
import { requireAdminPage } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users";
import { APP_URL, WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Admin-Einstellungen" };
}
export default async function AdminPage() {
const admin = await requireAdminPage();
const [settings, users, eligible, staleListeners] = await Promise.all([
getAppSettings(),
prisma.user.findMany({
select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }],
}),
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
// Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in
// der abgeglichen und begrenzt wird.
prisma.repository.count({
where: {
autoSync: true,
OR: [
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
{ repoUrl: { not: null } },
],
},
}),
prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } },
}),
]);
const data: AdminData = {
mode: settings.mode,
allowRegistration: settings.allowRegistration,
users,
currentUserId: admin.id,
appUrl: APP_URL,
passkeysAvailable: WEBAUTHN_AVAILABLE,
autoSync: {
enabled: settings.autoSyncEnabled,
intervalMinutes: settings.autoSyncIntervalMinutes,
activeOnly: settings.autoSyncActiveOnly,
batchSize: settings.autoSyncBatchSize,
lastRunAt: settings.lastAutoSyncAt,
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
eligible,
},
stale: {
afterDays: settings.staleAfterDays,
lastCheckAt: settings.lastStaleCheckAt,
listeners: staleListeners,
},
};
return (
<div className="mx-auto max-w-4xl space-y-6 pt-4">
<header className="fade-up">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Administration
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white">
Admin-Einstellungen
</h1>
<p className="mt-1 text-sm text-slate-400">
Betriebsart, automatischer Abgleich und Benutzerkonten.
</p>
</header>
<AdminManager data={data} />
</div>
);
}
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, passkeyNameSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Umbenennen – nur der eigene Passkey. */
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = passkeyNameSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
// Der Besitzvergleich steht in der where-Klausel, damit fremde Passkeys
// gar nicht erst getroffen werden.
const result = await prisma.credential.updateMany({
where: { id, userId: auth.user.id },
data: { name: parsed.data.name ?? null },
});
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
return NextResponse.json({ ok: true });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id: auth.user.id },
select: { passwordHash: true, _count: { select: { credentials: true } } },
});
if (!user) return jsonError("Konto nicht gefunden.", 404);
// Nicht das letzte Anmeldemittel entfernen.
if (!user.passwordHash && user._count.credentials <= 1) {
return jsonError(
"Das ist der letzte Passkey und es ist kein Passwort gesetzt. " +
"Bitte zuerst ein Passwort vergeben.",
409,
);
}
const result = await prisma.credential.deleteMany({ where: { id, userId: auth.user.id } });
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
return NextResponse.json({ ok: true });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength, verifyPassword } from "@/lib/auth/password";
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
import { changePasswordSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Eigenes Passwort setzen oder aendern.
*
* Wer bisher nur einen Passkey hat, darf ohne altes Passwort eines
* vergeben – die Sitzung selbst ist der Nachweis. Nach der Aenderung werden
* alle Sitzungen verworfen und die aktuelle neu ausgestellt.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = changePasswordSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
if (!user) return jsonError("Konto nicht gefunden.", 404);
if (user.passwordHash) {
const current = parsed.data.currentPassword ?? "";
const valid = await verifyPassword(current, user.passwordHash);
if (!valid) {
return NextResponse.json(
{
error: "Das aktuelle Passwort stimmt nicht.",
fields: { currentPassword: "Falsch" },
},
{ status: 401 },
);
}
}
const weak = validatePasswordStrength(parsed.data.newPassword, user.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
}
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: await hashPassword(parsed.data.newPassword) },
});
// Alle bestehenden Sitzungen entwerten und fuer dieses Geraet eine neue
// ausstellen, damit der Wechsel nicht abmeldet.
await destroyAllSessions(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({ ok: true });
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, profileUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function PATCH(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = profileUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { displayName, email } = parsed.data;
if (email) {
const taken = await prisma.user.findFirst({
where: { email, NOT: { id: auth.user.id } },
select: { id: true },
});
if (taken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.update({
where: { id: auth.user.id },
data: {
...(displayName !== undefined ? { displayName } : {}),
...(email !== undefined ? { email } : {}),
},
select: { id: true, username: true, displayName: true, email: true, role: true },
});
return NextResponse.json({ user });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { clientIp } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Uebersicht der eigenen aktiven Sitzungen. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const sessions = await prisma.session.findMany({
where: { userId: auth.user.id, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ip: true,
createdAt: true,
lastSeenAt: true,
expiresAt: true,
},
orderBy: { lastSeenAt: "desc" },
});
return NextResponse.json({ sessions });
}
/** Ueberall abmelden – das aktuelle Geraet bleibt angemeldet. */
export async function DELETE(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
await destroyAllSessions(auth.user.id);
const session = await createSession(auth.user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({ ok: true });
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { issueRecoveryCodes } from "@/lib/auth/recovery";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
export const dynamic = "force-dynamic";
const schema = z.object({ password: z.string().min(1).max(200) });
/**
* Neue Wiederherstellungscodes. Die alten verfallen dabei – sonst waeren
* Zettel im Umlauf, von denen niemand mehr weiss, welche noch gelten.
*
* Verlangt das Passwort, wie das Abschalten auch: die Codes sind gleichwertig
* zum zweiten Faktor.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data);
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { passwordHash: true, totpEnabledAt: true },
});
if (!user.totpEnabledAt) {
return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
}
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
return jsonError("Das Passwort stimmt nicht.", 401);
}
return NextResponse.json({ recoveryCodes: await issueRecoveryCodes(auth.user.id) });
}
+170
View File
@@ -0,0 +1,170 @@
import { NextResponse } from "next/server";
import QRCode from "qrcode";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, matchTotp, otpauthUri } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Zwei-Faktor-Anmeldung per Einmalkennwort.
*
* Die Einrichtung laeuft in zwei Schritten: POST legt einen Schluessel an und
* zeigt ihn als QR-Code, PUT schaltet ihn nach einem bestaetigten Code scharf.
* Erst dann fragt die Anmeldung danach. Wer die Einrichtung abbricht oder den
* QR-Code nie scannt, sperrt sich so nicht aus.
*/
/** Zustand fuer die Anzeige. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpEnabledAt: true },
});
return NextResponse.json({
enabled: Boolean(user.totpEnabledAt),
enabledAt: user.totpEnabledAt,
recoveryCodesLeft: user.totpEnabledAt
? await countUnusedRecoveryCodes(auth.user.id)
: 0,
});
}
/** Schritt 1: Schluessel erzeugen und als QR-Code zurueckgeben. */
export async function POST() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpEnabledAt: true, username: true },
});
if (user.totpEnabledAt) {
return jsonError(
"Die Zwei-Faktor-Anmeldung ist bereits aktiv. Bitte zuerst abschalten.",
409,
);
}
// Bei jedem Anlauf ein frischer Schluessel: ein halb eingerichteter aus
// einem frueheren Versuch koennte in einer Authenticator-App liegen, ohne
// dass hier bekannt waere, in welcher.
const secret = generateTotpSecret();
await prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: encryptSecret(secret) },
});
const uri = otpauthUri({ secret, account: user.username, issuer: APP_NAME });
return NextResponse.json({
// Das Bild als data:-URI, damit die Content-Security-Policy nichts
// nachladen muss. img-src erlaubt data: bereits.
qr: await QRCode.toDataURL(uri, { margin: 1, width: 320 }),
uri,
/** Zum Abtippen, falls die Kamera nicht mitspielt. */
secret: groupSecret(secret),
});
}
const enableSchema = z.object({ code: z.string().min(6).max(10) });
/** Schritt 2: Code bestaetigen, scharfschalten, Wiederherstellungscodes ausgeben. */
export async function PUT(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = enableSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpSecretCipher: true, totpEnabledAt: true },
});
if (user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist bereits aktiv.", 409);
const secret = tryDecryptSecret(user.totpSecretCipher);
if (!secret) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
}
const fenster = matchTotp(secret, parsed.data.code);
if (fenster === null) {
return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401,
);
}
await prisma.user.update({
where: { id: auth.user.id },
// Das Fenster gleich mitverbrennen: der Code, der eben aktiviert hat,
// soll nicht Sekunden spaeter noch zum Anmelden taugen.
data: { totpEnabledAt: new Date(), totpLastCounter: fenster },
});
return NextResponse.json({
ok: true,
// Einmalige Ausgabe: gespeichert wird nur ihr Hash.
recoveryCodes: await issueRecoveryCodes(auth.user.id),
});
}
const disableSchema = z.object({ password: z.string().min(1).max(200) });
/** Abschalten. Verlangt das Passwort, nicht nur eine offene Sitzung. */
export async function DELETE(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = disableSchema.safeParse(body.data);
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { passwordHash: true, totpEnabledAt: true },
});
if (!user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
// Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor
// nicht im Vorbeigehen entfernen koennen.
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
return jsonError("Das Passwort stimmt nicht.", 401);
}
await prisma.$transaction([
prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null, totpLastCounter: null },
}),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
]);
return NextResponse.json({ ok: true });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import {
SEITEN_ORDNUNG,
cursorAus,
seitenBedingung,
seitenGroesse,
} from "@/lib/timeline";
export const dynamic = "force-dynamic";
/**
* Der Verlauf ueber alle Projekte, neueste Eintraege zuerst.
*
* Geblaettert wird per Cursor (`vor` = Zeitpunkt, `id` = Eintrag), nicht per
* Offset – siehe lib/timeline. Zurueck kommt der Cursor fuer die naechste
* Seite gleich mit, damit der Aufrufer ihn nicht selbst zusammensetzen muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const params = new URL(request.url).searchParams;
const cursor = cursorAus(params.get("vor"), params.get("id"));
const take = seitenGroesse(params.get("limit"));
/**
* Einen mehr holen als angefragt: daran laesst sich ablesen, ob es
* weitergeht, ohne dafuer zaehlen zu muessen.
*/
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(auth.user.id, cursor),
orderBy: SEITEN_ORDNUNG,
take: take + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > take;
const activities = weitere ? gefunden.slice(0, take) : gefunden;
const letzter = activities[activities.length - 1];
return NextResponse.json({
activities: activities.map((eintrag) => ({
...eintrag,
createdAt: eintrag.createdAt.toISOString(),
})),
weitere,
naechster: weitere && letzter ? { vor: letzter.createdAt.toISOString(), id: letzter.id } : null,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { requireAdminApi } from "@/lib/auth/guard";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
import { exportFileName } from "@/lib/export/format";
export const dynamic = "force-dynamic";
/**
* Vollstaendige Sicherung der Instanz als JSON.
*
* Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt
* deshalb auch Konten und Verbindungen. Was drinsteht:
*
* - Passwoerter nur als scrypt-Hash, nicht im Klartext
* - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos
* - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und
* Passkeys haengen ohnehin am Geraet und an der rpID
*
* Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung
* der Datenbank siehe scripts/backup.* mit pg_dump.
*/
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const [settings, users, connections, projects] = await Promise.all([
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
prisma.user.findMany({
select: {
id: true,
username: true,
displayName: true,
email: true,
passwordHash: true,
role: true,
isActive: true,
createdAt: true,
lastLoginAt: true,
},
orderBy: { username: "asc" },
}),
prisma.gitConnection.findMany({
select: {
id: true,
ownerId: true,
name: true,
provider: true,
baseUrl: true,
username: true,
tokenCipher: true,
tokenHint: true,
createdAt: true,
},
orderBy: { name: "asc" },
}),
prisma.project.findMany({
include: {
notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross.
},
orderBy: { name: "asc" },
}),
]);
const payload = {
kind: "nebula.backup",
version: 1,
exportedAt: new Date().toISOString(),
app: APP_NAME,
note:
"Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " +
"Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.",
settings,
users,
connections,
projects,
};
const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json");
return new Response(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminSettingsSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const [settings, userCount] = await Promise.all([getAppSettings(), prisma.user.count()]);
return NextResponse.json({ settings, userCount });
}
export async function PATCH(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminSettingsSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const {
mode,
allowRegistration,
autoSyncEnabled,
autoSyncIntervalMinutes,
autoSyncActiveOnly,
autoSyncBatchSize,
staleAfterDays,
} = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
if (mode === "SINGLE_USER") {
const userCount = await prisma.user.count();
if (userCount > 1) {
return jsonError(
`Einzelbetrieb ist nicht moeglich, solange ${userCount} Konten existieren. ` +
"Bitte zuerst die uebrigen Konten entfernen.",
409,
);
}
}
const autoSyncFields = {
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
// Neue Schwelle: die Pruefung soll beim naechsten Tick greifen, nicht
// erst in 24 Stunden.
...(staleAfterDays !== undefined
? { staleAfterDays, lastStaleCheckAt: null }
: {}),
};
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}),
...autoSyncFields,
},
update: {
...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}),
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
...autoSyncFields,
},
});
return NextResponse.json({ settings });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
export const dynamic = "force-dynamic";
/**
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
*
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
* eingebauten Automatismus zu nutzen.
*/
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`sync-run:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
if (isAutoSyncRunning()) {
return jsonError("Es laeuft bereits ein Abgleich.", 409);
}
const report = await runAutoSyncPass("manuell");
if (!report) {
return jsonError("Es laeuft bereits ein Abgleich.", 409);
}
return NextResponse.json({ report });
}
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { destroyAllSessions } from "@/lib/auth/session";
import { userSafeSelect } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
const target = await prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (target?.role !== "ADMIN") return false;
const activeAdmins = await prisma.user.count({
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
});
return activeAdmins === 0;
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminUserUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return jsonError("Konto nicht gefunden.", 404);
const { displayName, email, role, isActive, password } = parsed.data;
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
return jsonError(
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
409,
);
}
if (email) {
const taken = await prisma.user.findFirst({
where: { email, NOT: { id } },
select: { id: true },
});
if (taken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
if (password) {
const weak = validatePasswordStrength(password, target.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
}
const user = await prisma.user.update({
where: { id },
data: {
...(displayName !== undefined ? { displayName } : {}),
...(email !== undefined ? { email } : {}),
...(role ? { role } : {}),
...(isActive !== undefined ? { isActive } : {}),
...(password ? { passwordHash: await hashPassword(password) } : {}),
// Ein Eingriff des Administrators hebt eine Sperre auf.
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
},
select: userSafeSelect,
});
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
if (isActive === false || password) {
await destroyAllSessions(id);
}
return NextResponse.json({ user });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (id === auth.user.id) {
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
}
if (await wouldRemoveLastAdmin(id)) {
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
}
try {
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
// onDelete: Cascade daran und verschwinden mit.
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return jsonError("Konto nicht gefunden.", 404);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { getInstanceState } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const users = await prisma.user.findMany({
select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }],
});
return NextResponse.json({ users });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const state = await getInstanceState();
if (!state.multiUser) {
return jsonError(
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
409,
);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminUserCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
if (taken) {
return NextResponse.json(
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
{ status: 409 },
);
}
if (email) {
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (emailTaken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role,
},
select: userSafeSelect,
});
return NextResponse.json({ user }, { status: 201 });
}
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, hashPassword, needsRehash, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { loginSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
const GENERIC = "Benutzername oder Passwort ist falsch.";
export async function POST(request: Request) {
const ip = clientIp(request);
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = loginSchema.safeParse(body.data);
if (!parsed.success) return jsonError(GENERIC, 401);
const { username, password } = parsed.data;
const byUser = rateLimit(
`login:user:${username}`,
LIMITS.loginUser.limit,
LIMITS.loginUser.windowMs,
);
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
await fakeVerifyDelay();
return jsonError(GENERIC, 401);
}
/**
* Erst das Passwort pruefen, dann ueber den Kontozustand reden.
*
* Frueher standen die Abfragen auf gesperrt, deaktiviert und
* "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich
* damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet,
* bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit,
* die der einheitliche Text GENERIC sonst verhindert.
*
* Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die
* hilfreichere Auskunft bekommt nur, wer das Passwort kennt.
*/
const valid = user.passwordHash
? await verifyPassword(password, user.passwordHash)
: // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit
// verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt.
await fakeVerifyDelay().then(() => false);
if (!valid) {
// Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt.
if (user.passwordHash) await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) {
return jsonError(
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
423,
);
}
if (!user.isActive) {
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
/**
* Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort
* anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde,
* bekommt hier still einen neuen – es ist der einzige Moment, in dem das
* Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf
* die halbe Sekunde wartet.
*/
if (needsRehash(user.passwordHash)) {
void hashPassword(password)
.then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } }))
.catch(() => undefined);
}
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
// noch keine Sitzung, sondern nur ein Vorgang mit kurzer Frist.
if (user.totpEnabledAt) {
// Die Zaehler bleiben stehen, bis die Anmeldung wirklich durch ist:
// sonst liesse sich ueber wiederholte Passworteingaben die Sperre
// umgehen, die falsche Passwoerter aufbaut.
const token = await createPendingLogin(user.id);
const response = NextResponse.json({ ok: false, mfaRequired: true });
setMfaCookie(response, token);
return response;
}
await noteSuccessfulLogin(user.id);
resetRateLimit(`login:user:${username}`);
resetRateLimit(`login:ip:${ip}`);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { clearSessionCookie, destroyCurrentSession } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
export async function POST() {
await destroyCurrentSession();
const response = NextResponse.json({ ok: true });
clearSessionCookie(response);
return response;
}
+117
View File
@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
clearMfaCookie,
discardPendingLogin,
noteMfaFailure,
readPendingLogin,
} from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { matchTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/**
* Der zweite Schritt der Anmeldung: Einmalkennwort oder
* Wiederherstellungscode.
*
* Der Vorgang steckt im kurzlebigen Cookie aus Schritt eins. Wer hier ohne
* diesen Cookie ankommt, hat kein Passwort vorgezeigt und bekommt nichts.
*/
const schema = z.object({
code: z.string().min(1).max(40),
});
const GENERIC = "Der Code stimmt nicht.";
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`mfa:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data);
if (!parsed.success) return jsonError(GENERIC, 401);
const pending = await readPendingLogin();
if (!pending) {
const response = jsonError(
"Die Anmeldung ist abgelaufen. Bitte melde dich neu an.",
401,
);
clearMfaCookie(response);
return response;
}
if (!pending.user.isActive) {
await discardPendingLogin(pending.id);
const response = jsonError("Dieses Konto ist deaktiviert.", 403);
clearMfaCookie(response);
return response;
}
const eingabe = parsed.data.code.trim();
// Sechs Ziffern sind ein Einmalkennwort, alles andere ein
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe);
// Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
// das zuletzt eingeloeste. Sonst liesse sich ein abgefangener Code die
// restliche Gueltigkeitsdauer ueber wiederverwenden – RFC 6238, 5.2.
const fenster =
istZiffern && pending.user.totpSecret
? matchTotp(pending.user.totpSecret, eingabe)
: null;
const frisch =
fenster !== null &&
(pending.user.totpLastCounter === null || fenster > pending.user.totpLastCounter);
const erfolg = istZiffern ? frisch : await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id);
if (!weiterMoeglich) {
const response = jsonError(
"Zu viele Fehlversuche. Bitte melde dich neu an.",
429,
);
clearMfaCookie(response);
return response;
}
return jsonError(GENERIC, 401);
}
await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id);
// Das eingeloeste Fenster merken, damit derselbe Code verbrannt ist.
if (fenster !== null) {
await prisma.user.update({
where: { id: pending.user.id },
data: { totpLastCounter: fenster },
});
}
const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: pending.user.id, username: pending.user.username, role: pending.user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
clearMfaCookie(response);
return response;
}
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { WebAuthnUnavailableError, buildAuthenticationOptions } from "@/lib/auth/webauthn";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/**
* Optionen fuer navigator.credentials.get(). Ohne allowCredentials – der
* Browser bietet alle passenden Passkeys an, ein Benutzername ist nicht
* noetig. Der Endpunkt verraet dadurch auch nichts ueber vorhandene Konten.
*/
export async function POST(request: Request) {
const limited = rateLimit(
`webauthn:auth:${clientIp(request)}`,
LIMITS.webauthn.limit,
LIMITS.webauthn.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
try {
const options = await buildAuthenticationOptions();
return NextResponse.json({ options });
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 500);
}
}
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { WebAuthnUnavailableError, confirmAuthentication } from "@/lib/auth/webauthn";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
interface Payload {
response?: AuthenticationResponseJSON;
}
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`passkey:login:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody<Payload>(request);
if (!body.ok) return body.response;
const assertion = body.data?.response;
if (!assertion || typeof assertion !== "object") {
return jsonError("Die Antwort des Authenticators fehlt.", 400);
}
try {
const result = await confirmAuthentication(assertion);
const user = await prisma.user.findUnique({ where: { id: result.userId } });
if (!user || !user.isActive) {
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
await noteSuccessfulLogin(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 401);
}
}
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { WebAuthnUnavailableError, buildRegistrationOptions } from "@/lib/auth/webauthn";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Liefert die Optionen fuer navigator.credentials.create(). */
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`webauthn:reg:${clientIp(request)}`,
LIMITS.webauthn.limit,
LIMITS.webauthn.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
try {
const options = await buildRegistrationOptions({
id: auth.user.id,
username: auth.user.username,
displayName: auth.user.displayName,
});
return NextResponse.json({ options });
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 500);
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import { requireUserApi } from "@/lib/auth/guard";
import { WebAuthnUnavailableError, confirmRegistration } from "@/lib/auth/webauthn";
import { jsonError, readJsonBody } from "@/lib/security/request";
export const dynamic = "force-dynamic";
interface Payload {
response?: RegistrationResponseJSON;
name?: string | null;
}
/** Nimmt die Antwort des Authenticators entgegen und speichert den Passkey. */
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody<Payload>(request);
if (!body.ok) return body.response;
const attestation = body.data?.response;
if (!attestation || typeof attestation !== "object") {
return jsonError("Die Antwort des Authenticators fehlt.", 400);
}
try {
const credential = await confirmRegistration(
auth.user.id,
attestation,
typeof body.data?.name === "string" ? body.data.name : null,
);
return NextResponse.json({
ok: true,
credential: {
id: credential.id,
name: credential.name,
deviceType: credential.deviceType,
backedUp: credential.backedUp,
createdAt: credential.createdAt,
},
});
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 400);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, registerSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Selbstregistrierung. Nur moeglich, wenn die Instanz im Mehrbenutzerbetrieb
* laeuft und ein Administrator die Registrierung freigeschaltet hat.
*/
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`register:${ip}`, 5, 60 * 60 * 1000);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const state = await getInstanceState();
if (state.needsSetup) {
return jsonError("Die Instanz ist noch nicht eingerichtet.", 409);
}
if (!state.registrationOpen) {
return jsonError("Die Registrierung ist auf dieser Instanz deaktiviert.", 403);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = registerSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password } = parsed.data;
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
if (taken) {
return NextResponse.json(
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
{ status: 409 },
);
}
if (email) {
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (emailTaken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role: "USER",
// Die Registrierung meldet direkt an.
lastLoginAt: new Date(),
},
});
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
export const dynamic = "force-dynamic";
/** Zustand fuer die Anmeldeseite und zum Pruefen der eigenen Sitzung. */
export async function GET() {
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
return NextResponse.json({
user,
needsSetup: state.needsSetup,
registrationOpen: state.registrationOpen,
multiUser: state.multiUser,
passkeysAvailable: WEBAUTHN_AVAILABLE,
});
}
+78
View File
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { claimOrphanedData } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, setupSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Erstmalige Einrichtung: legt das Administratorkonto an.
*
* Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der
* Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto
* erschleichen.
*/
export async function POST(request: Request) {
const existing = await prisma.user.count();
if (existing > 0) {
return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = setupSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json(
{ error: weak, fields: { password: weak } },
{ status: 422 },
);
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role: "ADMIN",
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
lastLoginAt: new Date(),
},
});
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", mode },
update: { mode },
});
const claimed = await claimOrphanedData(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
claimed,
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { clientForConnection } from "@/lib/git";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Testet Erreichbarkeit und Token einer Verbindung und merkt sich das Ergebnis. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`outbound:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const connection = await prisma.gitConnection.findFirst({
where: { id, ownerId: auth.user.id },
});
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
const result = await clientForConnection(connection).check();
await prisma.gitConnection.update({
where: { id },
data: {
lastCheckedAt: new Date(),
lastCheckOk: result.ok,
lastCheckMessage: result.message,
username: result.user ?? connection.username,
},
});
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { clientForConnection } from "@/lib/git";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Listet Repositories, auf die der hinterlegte Token Zugriff hat. */
export async function GET(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`outbound:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const query = new URL(request.url).searchParams.get("q") ?? undefined;
const connection = await prisma.gitConnection.findFirst({
where: { id, ownerId: auth.user.id },
});
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
try {
const repos = await clientForConnection(connection).listRepos(query);
return NextResponse.json({ repos });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 502 },
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { normalizeBaseUrl } from "@/lib/git/http";
import { connectionSafeSelect } from "@/lib/projects";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { connectionUpdateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
const safeSelect = {
...connectionSafeSelect,
_count: { select: { repositories: true } },
};
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.gitConnection.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Verbindung nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = connectionUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.name !== undefined) update.name = data.name;
if (data.provider !== undefined) update.provider = data.provider;
if (data.username !== undefined) update.username = data.username;
if (data.baseUrl !== undefined) {
const normalized = normalizeBaseUrl(data.baseUrl);
try {
await assertAllowedUrl(normalized);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { baseUrl: error.message } },
{ status: 422 },
);
}
throw error;
}
update.baseUrl = normalized;
}
// token === null -> Token entfernen
// token === "" -> vom Schema zu null normalisiert
// token undefined -> unveraendert lassen
if (data.token !== undefined) {
if (data.token) {
update.tokenCipher = encryptSecret(data.token);
update.tokenHint = tokenHint(data.token);
} else {
update.tokenCipher = null;
update.tokenHint = null;
}
}
if (data.name !== undefined) {
const duplicate = await prisma.gitConnection.findFirst({
where: { name: data.name, ownerId: auth.user.id, NOT: { id } },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
}
const connection = await prisma.gitConnection.update({
where: { id },
data: update,
select: safeSelect,
});
return NextResponse.json({ connection });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert.
const result = await prisma.gitConnection.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Verbindung nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
+98
View File
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { normalizeBaseUrl } from "@/lib/git/http";
import { connectionSafeSelect } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { connectionCreateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
const safeSelect = {
...connectionSafeSelect,
_count: { select: { repositories: true } },
};
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const connections = await prisma.gitConnection.findMany({
where: { ownerId: auth.user.id },
select: safeSelect,
orderBy: { name: "asc" },
});
return NextResponse.json({ connections });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = connectionCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { name, provider, baseUrl, username, token } = parsed.data;
// Die Basis-URL wird spaeter serverseitig abgerufen – hier pruefen, damit
// sie nicht auf interne Dienste oder Metadaten-Endpunkte zeigt.
try {
await assertAllowedUrl(normalizeBaseUrl(baseUrl));
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { baseUrl: error.message } },
{ status: 422 },
);
}
throw error;
}
const duplicate = await prisma.gitConnection.findFirst({
where: { name, ownerId: auth.user.id },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{
error: "Es gibt bereits eine Verbindung mit diesem Namen.",
fields: { name: "Name bereits vergeben" },
},
{ status: 409 },
);
}
const connection = await prisma.gitConnection.create({
data: {
ownerId: auth.user.id,
name,
provider,
baseUrl: normalizeBaseUrl(baseUrl),
username: username ?? null,
tokenCipher: token ? encryptSecret(token) : null,
tokenHint: token ? tokenHint(token) : null,
},
select: safeSelect,
});
return NextResponse.json({ connection }, { status: 201 });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Liveness/Readiness-Endpoint – wird auch vom Docker-Healthcheck benutzt. */
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: "ok",
database: "up",
time: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
status: "degraded",
database: "down",
error: error instanceof Error ? error.message : String(error),
},
{ status: 503 },
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, noteUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Eine Notiz gehoert dem, dem das zugehoerige Projekt gehoert. */
async function ownedNote(noteId: string, ownerId: string) {
return prisma.note.findFirst({
where: { id: noteId, project: { ownerId } },
select: { id: true, projectId: true },
});
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const note = await ownedNote(id, auth.user.id);
if (!note) return jsonError("Notiz nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = noteUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const updated = await prisma.note.update({
where: { id },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
},
});
// Nur Anheften ist keine inhaltliche Aenderung und kommt nicht in den
// Verlauf – sonst steht dort dreimal dieselbe Notiz, weil jemand unschluessig war.
if (parsed.data.title !== undefined || parsed.data.content !== undefined) {
await recordActivity({
projectId: note.projectId,
userId: auth.user.id,
kind: "NOTE_UPDATED",
summary: `Notiz bearbeitet: ${shorten(updated.title || updated.content)}`,
meta: { noteId: id },
});
}
return NextResponse.json({ note: updated });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await prisma.note.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: { projectId: true, title: true, content: true },
});
const result = await prisma.note.deleteMany({
where: { id, project: { ownerId: auth.user.id } },
});
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
if (vorher) {
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "NOTE_DELETED",
summary: `Notiz gelöscht: ${shorten(vorher.title || vorher.content)}`,
});
}
return NextResponse.json({ ok: true });
}
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { targetSafeSelect } from "@/lib/notify/types";
import { formatZodError, notificationTargetUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!existing) return jsonError("Ziel nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.name !== undefined) update.name = data.name;
if (data.events !== undefined) update.events = data.events;
if (data.isActive !== undefined) update.isActive = data.isActive;
if (data.target !== undefined) {
if (existing.channel !== "EMAIL") {
try {
await assertAllowedUrl(data.target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
update.target = data.target;
}
// Leeres Feld heisst "unveraendert", null heisst "entfernen".
if (data.secret !== undefined) {
if (data.secret) {
update.secretCipher = encryptSecret(data.secret);
update.secretHint = tokenHint(data.secret);
} else {
update.secretCipher = null;
update.secretHint = null;
}
}
try {
const target = await prisma.notificationTarget.update({
where: { id },
data: update,
select: targetSafeSelect,
});
return NextResponse.json({ target });
} catch {
return jsonError("Name bereits vergeben.", 409);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const result = await prisma.notificationTarget.deleteMany({
where: { id, userId: auth.user.id },
});
if (result.count === 0) return jsonError("Ziel nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { deliver } from "@/lib/notify/channels";
import { testMessage } from "@/lib/notify";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Schickt eine Testnachricht an genau dieses Ziel. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Loest einen ausgehenden Aufruf aus – entsprechend begrenzt.
const limited = rateLimit(
`notify-test:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const target = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!target) return jsonError("Ziel nicht gefunden", 404);
const result = await deliver(target, testMessage());
await prisma.notificationTarget.update({
where: { id },
data: { lastSentAt: new Date(), lastOk: result.ok, lastMessage: result.message },
});
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
+83
View File
@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { targetSafeSelect } from "@/lib/notify/types";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { formatZodError, notificationTargetCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const targets = await prisma.notificationTarget.findMany({
where: { userId: auth.user.id },
select: targetSafeSelect,
orderBy: { name: "asc" },
});
return NextResponse.json({ targets });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { name, channel, target, secret, events, isActive } = parsed.data;
// Webhook und ntfy ruft der Server spaeter selbst auf – gleiche Pruefung
// wie bei den Git-Verbindungen.
if (channel !== "EMAIL") {
try {
await assertAllowedUrl(target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
const duplicate = await prisma.notificationTarget.findFirst({
where: { userId: auth.user.id, name },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
const created = await prisma.notificationTarget.create({
data: {
userId: auth.user.id,
name,
channel,
target,
secretCipher: secret ? encryptSecret(secret) : null,
secretHint: secret ? tokenHint(secret) : null,
events,
isActive,
},
select: targetSafeSelect,
});
return NextResponse.json({ target: created }, { status: 201 });
}
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { ACTIVITY_LIMIT } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Der Verlauf eines Projekts, neueste Eintraege zuerst. */
export async function GET(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
// Number(null) waere 0 und damit endlich – ohne die Pruefung auf den
// fehlenden Parameter kaeme genau ein Eintrag zurueck.
const roh = new URL(request.url).searchParams.get("limit");
const angefragt = roh === null ? Number.NaN : Number(roh);
const take = Number.isFinite(angefragt)
? Math.min(Math.max(angefragt, 1), ACTIVITY_LIMIT)
: 30;
const activities = await prisma.activity.findMany({
where: { projectId: id },
orderBy: { createdAt: "desc" },
take,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
});
return NextResponse.json({ activities });
}
+63
View File
@@ -0,0 +1,63 @@
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileName } from "@/lib/export/format";
import { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Ein einzelnes Projekt exportieren.
*
* ?format=json (Vorgabe) – zum Wiedereinlesen
* ?format=md – lesbar, zum Archivieren
*/
export async function GET(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const format = new URL(request.url).searchParams.get("format") ?? "json";
const project = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
include: {
...exportInclude,
// Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
// die Datei auch ohne Netz vollstaendig ist.
repositories: {
include: {
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
if (!project) return jsonError("Projekt nicht gefunden", 404);
if (format === "md" || format === "markdown") {
const filename = exportFileName(project.slug, new Date(), "md");
return new Response(toMarkdown(project), {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
const payload = buildExportFile([project]);
const filename = exportFileName(project.slug, new Date(), "json");
return new Response(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, noteCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Prueft in einem Zug Existenz und Besitz des Projekts. */
async function ownedProject(projectId: string, ownerId: string) {
return prisma.project.findFirst({
where: { id: projectId, ownerId },
select: { id: true },
});
}
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const notes = await prisma.note.findMany({
where: { projectId: id },
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
});
return NextResponse.json({ notes });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = noteCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const note = await prisma.note.create({
data: {
projectId: id,
title: parsed.data.title ?? null,
content: parsed.data.content,
pinned: parsed.data.pinned,
},
});
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "NOTE_ADDED",
summary: `Notiz angelegt: ${shorten(note.title || note.content)}`,
meta: { noteId: note.id },
});
return NextResponse.json({ note }, { status: 201 });
}
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import {
completeRepoInput,
namesRepository,
nextRepoPosition,
repoTitle,
} from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
import { formatZodError, repositoryCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */
const MAX_REPOS = 20;
const include = {
connection: { select: connectionSafeSelect },
snapshot: true,
} as const;
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const repositories = await prisma.repository.findMany({
where: { projectId: id },
include,
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ repositories });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = repositoryCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (!namesRepository(eingabe)) {
return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422);
}
// Eine fremde Verbindung darf nicht untergeschoben werden.
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const vorhanden = await prisma.repository.count({ where: { projectId: id } });
if (vorhanden >= MAX_REPOS) {
return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409);
}
// Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen.
if (eingabe.repoOwner && eingabe.repoName) {
const doppelt = await prisma.repository.findFirst({
where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName },
select: { id: true },
});
if (doppelt) {
return jsonError(
`${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`,
409,
);
}
}
const repository = await prisma.repository.create({
data: {
projectId: id,
label: eingabe.label ?? null,
repoUrl: eingabe.repoUrl ?? null,
repoOwner: eingabe.repoOwner ?? null,
repoName: eingabe.repoName ?? null,
connectionId: eingabe.connectionId ?? null,
autoSync: eingabe.autoSync ?? true,
position: await nextRepoPosition(id),
},
include,
});
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`,
meta: { repositoryId: repository.id },
});
return NextResponse.json({ repository }, { status: 201 });
}
+196
View File
@@ -0,0 +1,196 @@
import type { ProjectStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import {
nextProjectPosition,
projectDetailInclude,
projectListInclude,
uniqueSlug,
} from "@/lib/projects";
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const project = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
include: projectDetailInclude,
});
// Fremde Projekte werden wie nicht vorhandene behandelt – die Antwort
// verraet also nicht, dass es die ID gibt.
if (!project) return jsonError("Projekt nicht gefunden", 404);
return NextResponse.json({ project });
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const existing = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
});
if (!existing) return jsonError("Projekt nicht gefunden", 404);
const data = parsed.data;
if (data.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: data.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const update: Record<string, unknown> = {};
if (data.name !== undefined && data.name !== existing.name) {
update.name = data.name;
update.slug = await uniqueSlug(data.name, auth.user.id, id);
}
for (const key of [
"summary",
"description",
"status",
"priority",
"progress",
"accent",
"tags",
"favorite",
"progressFromTasks",
] as const) {
if (data[key] !== undefined) update[key] = data[key];
}
// Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
// seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
// angekommen ist.
if (data.status !== undefined && data.status !== existing.status) {
update.position = await nextProjectPosition(auth.user.id, data.status);
}
await prisma.project.update({ where: { id }, data: update });
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
// anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang.
const repoFelder = {
repoUrl: data.repoUrl,
repoOwner: data.repoOwner,
repoName: data.repoName,
connectionId: data.connectionId,
autoSync: data.autoSync,
};
const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined);
if (repoBeruehrt) await applyLeadingRepo(id, repoFelder);
await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null);
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) {
await refreshDerivedProgress(id);
}
const project = await prisma.project.findUniqueOrThrow({
where: { id },
include: projectListInclude,
});
return NextResponse.json({ project });
}
/**
* Haelt fest, was sich geaendert hat. Status und Repo-Verknuepfung bekommen
* einen eigenen Eintrag – sie sind die Vorgaenge, die man spaeter sucht. Alles
* uebrige fasst ein Eintrag zusammen, sonst ertraenkt eine Feinjustage der
* Farbe den Verlauf.
*/
async function noteChanges(
projectId: string,
userId: string,
before: { status: ProjectStatus },
update: Record<string, unknown>,
repo: RepoInput | null,
) {
const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug");
const entries: Parameters<typeof recordActivities>[0] = [];
if (update.status !== undefined && update.status !== before.status) {
const to = update.status as ProjectStatus;
entries.push({
projectId,
userId,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, to),
meta: { from: before.status, to },
});
}
// Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische
// Abgleich an oder aus ist, gehoert zu den uebrigen Feldern.
if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) {
const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null);
entries.push({
projectId,
userId,
kind: "REPO_LINKED",
summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert",
meta: { repoUrl: repo.repoUrl ?? null },
});
}
if (repo?.autoSync !== undefined) rest.push("autoSync");
if (repo?.connectionId !== undefined) rest.push("connectionId");
const felder = describeFields(rest);
if (felder) {
entries.push({
projectId,
userId,
kind: "PROJECT_UPDATED",
summary: felder,
meta: { fields: rest },
});
}
await recordActivities(entries);
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// deleteMany mit Besitzerbedingung: trifft fremde Projekte gar nicht erst.
const result = await prisma.project.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Projekt nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { projectDetailInclude } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
import { syncProject } from "@/lib/sync";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Holt Branches, Tags, Commits und Releases beim Git-System ab. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger
// begrenzt als bei reinen Datenbankzugriffen.
const limited = rateLimit(
`outbound:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const owned = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!owned) return jsonError("Projekt nicht gefunden", 404);
const result = await syncProject(id);
const project = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
include: projectDetailInclude,
});
return NextResponse.json({ ...result, project }, { status: result.ok ? 200 : 502 });
}
@@ -0,0 +1,147 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { repoTitle } from "@/lib/repositories";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { asArray, type TicketInfo } from "@/types";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
const schema = z.object({
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
numbers: z.array(z.number().int()).max(200).optional(),
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
repositoryId: z.string().optional(),
});
/**
* Uebernimmt offene Issues des verknuepften Repositories als Aufgaben.
*
* Grundlage ist der zuletzt geholte Snapshot – hier wird nichts nachgeladen,
* damit ein Klick nicht ungefragt fremde APIs anspricht. Wer den neuesten
* Stand will, gleicht vorher ab.
*
* Bereits uebernommene Issues werden uebersprungen: die Nummer steht an der
* Aufgabe und ist je Projekt eindeutig. Ein zweiter Aufruf legt also keine
* Dubletten an, auch wenn die Aufgabe inzwischen verschoben oder umbenannt
* wurde.
*/
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data ?? {});
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
const repository = await prisma.repository.findFirst({
where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: { snapshot: true },
});
if (!repository) {
// 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt,
// hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden".
return parsed.data.repositoryId
? jsonError("Repository nicht gefunden", 404)
: jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409);
}
const snapshot = repository.snapshot;
if (!snapshot) {
return jsonError(
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
409,
);
}
const alle = asArray<TicketInfo>(snapshot.issues);
const gewuenscht = parsed.data.numbers;
const auswahl = gewuenscht?.length
? alle.filter((issue) => gewuenscht.includes(issue.number))
: alle;
if (auswahl.length === 0) {
return NextResponse.json({ created: 0, skipped: 0, tasks: [] });
}
const vorhanden = await prisma.task.findMany({
where: {
projectId: id,
sourceRepositoryId: repository.id,
sourceIssue: { in: auswahl.map((issue) => issue.number) },
},
select: { sourceIssue: true },
});
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
const neu = auswahl.filter((issue) => !bekannt.has(issue.number));
let position = await nextPosition(id, "TODO");
const angelegt = [];
for (const issue of neu) {
angelegt.push(
await prisma.task.create({
data: {
projectId: id,
title: issue.title.slice(0, 200),
// Der Link ist das Wichtigste – der Text steht im Git-System.
description: issue.url ? `[Issue #${issue.number}](${issue.url})` : null,
status: "TODO",
position: position++,
labels: issue.labels.slice(0, 8),
sourceIssue: issue.number,
sourceUrl: issue.url,
sourceRepositoryId: repository.id,
},
}),
);
}
if (angelegt.length > 0) {
await refreshDerivedProgress(id);
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASKS_IMPORTED",
summary:
angelegt.length === 1
? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen`
: `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`,
meta: {
repositoryId: repository.id,
numbers: angelegt.map((task) => task.sourceIssue),
},
});
}
return NextResponse.json({
created: angelegt.length,
skipped: auswahl.length - angelegt.length,
tasks: angelegt,
});
}
@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Neue Reihenfolge nach dem Ziehen.
*
* Uebergeben wird je betroffener Spalte die vollstaendige Liste der Aufgaben
* in ihrer neuen Reihenfolge. Daraus ergeben sich Status und Position
* eindeutig – der Aufruf ist damit wiederholbar, ohne Schaden anzurichten,
* und ein verlorener Aufruf laesst sich einfach erneut senden.
*/
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<TaskStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur Aufgaben anfassen, die wirklich zu diesem Projekt gehoeren – sonst
// liessen sich fremde Aufgaben ueber untergeschobene IDs verschieben.
const owned = await prisma.task.findMany({
where: { projectId: id, id: { in: requestedIds } },
select: { id: true, status: true },
});
const ownedIds = new Set(owned.map((task) => task.id));
const previousStatus = new Map(owned.map((task) => [task.id, task.status]));
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
/** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */
const frischErledigt: string[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
data: {
status,
position: index,
// doneAt nur beim tatsaechlichen Wechsel setzen oder loeschen,
// damit ein Umsortieren innerhalb von "Erledigt" das Datum nicht
// immer wieder erneuert.
...(before !== status
? { doneAt: status === "DONE" ? now : null }
: {}),
},
}),
);
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
for (const taskId of frischErledigt) {
await spawnRecurrence(taskId);
}
await refreshDerivedProgress(id);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({
tasks,
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({ tasks });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { title, description, status, dueDate, labels, recurrence } = parsed.data;
const task = await prisma.task.create({
data: {
projectId: id,
title,
description: description ?? null,
status,
// Neue Aufgaben landen unten in ihrer Spalte.
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null,
},
});
await refreshDerivedProgress(id);
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
meta: { taskId: task.id },
});
return NextResponse.json({ task }, { status: 201 });
}
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectBulkSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
*
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectBulkSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { ids, action } = parsed.data;
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
// erst eingesammelt und fallen damit still unter den Tisch.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: ids } },
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
});
if (owned.length === 0) {
return NextResponse.json({ changed: 0, ignored: ids.length });
}
const eigene = owned.map((project) => project.id);
const entries: Parameters<typeof recordActivities>[0] = [];
if (action === "delete") {
const result = await prisma.project.deleteMany({
where: { ownerId: auth.user.id, id: { in: eigene } },
});
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
}
const updates: Prisma.PrismaPromise<unknown>[] = [];
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
// alle Verschobenen landeten uebereinander auf derselben Position.
let position =
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
for (const project of owned) {
if (action === "status") {
const ziel = parsed.data.status!;
if (project.status === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
data: { status: ziel, position: position++ },
}),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(project.status, ziel),
meta: { from: project.status, to: ziel, bulk: true },
});
continue;
}
if (action === "favorite") {
const ziel = parsed.data.favorite!;
if (project.favorite === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ein Stern ist keine inhaltliche Aenderung.
data: { favorite: ziel, updatedAt: project.updatedAt },
}),
);
continue;
}
// action === "tags"
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
const naechste =
parsed.data.tagMode === "add"
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
: project.tags.filter((tag) => !gewuenscht.includes(tag));
if (naechste.length === project.tags.length &&
naechste.every((tag, index) => tag === project.tags[index])) {
continue;
}
updates.push(
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_UPDATED",
summary: "Tags geändert",
meta: { tags: naechste, bulk: true },
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
changed: updates.length,
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
ignored: ids.length - updates.length,
});
}
/** Naechste freie Position am Ende einer Statusspalte. */
async function nextPosition(ownerId: string, status: ProjectStatus) {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
+29
View File
@@ -0,0 +1,29 @@
import { requireUserApi } from "@/lib/auth/guard";
import { APP_NAME } from "@/lib/config";
import { exportFileName } from "@/lib/export/format";
import { buildExportFile, loadProjectsForExport } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Alle eigenen Projekte als JSON zum Herunterladen. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const projects = await loadProjectsForExport(auth.user.id);
if (projects.length === 0) {
return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404);
}
const payload = buildExportFile(projects);
const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json");
return new Response(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}

Some files were not shown because too many files have changed in this diff Show More