package store import ( "crypto/rand" "encoding/hex" "errors" "strconv" "strings" "time" ) // ErrNotFound meldet, dass ein gesuchter Datensatz nicht existiert. // // Ein eigener Fehlerwert statt sql.ErrNoRows: die aufrufenden Schichten sollen // nicht wissen müssen, dass dahinter überhaupt SQL steckt. Geprüft wird mit // errors.Is(err, store.ErrNotFound). var ErrNotFound = errors.New("nicht gefunden") // ErrConflict meldet einen Schreibkonflikt beim optimistischen Sperren: // der Eintrag wurde verändert, seit der Schreiber ihn gelesen hat. var ErrConflict = errors.New("Eintrag wurde zwischenzeitlich geändert") // ErrDuplicate meldet, dass ein eindeutiger Wert schon vergeben ist // (Projekt-Slug, Eintrags-Slug). var ErrDuplicate = errors.New("existiert bereits") // ── Datentypen ─────────────────────────────────────────────────────────────── // Workspace ist die oberste Klammer. Vorerst gibt es genau einen. type Workspace struct { ID string Name string CreatedAt time.Time } // Project ist die Einheit der Abschottung: eigene Einträge, eigene Keys. type Project struct { ID string WorkspaceID string Slug string Name string Description string CreatedAt time.Time // WriteLimitHourly ist die eigene Obergrenze für schreibende MCP-Aufrufe je // Stunde. 0 bedeutet nicht "gesperrt", sondern "keine eigene Angabe" — dann // gilt der Standard aus WRITE_LIMIT_HOURLY (siehe auth.WriteBudget). WriteLimitHourly int } // APIKey ist ein Zugangsschlüssel für genau ein Projekt. // Das Feld für den Klartext-Key fehlt bewusst — gespeichert wird nur der Hash. type APIKey struct { ID string ProjectID string Name string Hash string Role string CreatedAt time.Time LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt RevokedAt time.Time // Nullzeitpunkt = aktiv ExpiresAt time.Time // Nullzeitpunkt = läuft nie ab (migrations/007) } // Revoked sagt, ob der Key zurückgezogen wurde. func (k APIKey) Revoked() bool { return !k.RevokedAt.IsZero() } // Expired sagt, ob der Key abgelaufen ist. Ein abgelaufener Key gilt wie ein // zurückgezogener (siehe ResolveKeyHash und sessionFromSubject). func (k APIKey) Expired(jetzt time.Time) bool { return !k.ExpiresAt.IsZero() && !jetzt.Before(k.ExpiresAt) } // Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen. func (k APIKey) Valid(jetzt time.Time) bool { return !k.Revoked() && !k.Expired(jetzt) } // ExpiresSoonWithin ist die Zeitspanne vor dem Ablauf, in der die Oberfläche // einen Key als „läuft bald ab" markiert — Zeit genug, einen neuen anzulegen // und in den Clients auszutauschen. const ExpiresSoonWithin = 14 * 24 * time.Hour // ExpiresSoon sagt, ob ein gültiger Key innerhalb von ExpiresSoonWithin // abläuft. func (k APIKey) ExpiresSoon(jetzt time.Time) bool { return k.Valid(jetzt) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(jetzt) <= ExpiresSoonWithin } // UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als // „ungenutzt“ gilt (siehe AUSBAUSTUFEN.md 4.7). const UnusedAfter = 90 * 24 * time.Hour // Unused sagt, ob ein aktiver Key seit mehr als UnusedAfter nicht benutzt wurde. // // Wurde der Key noch nie benutzt, zählt stattdessen das Anlegedatum. Sonst // stünde jeder frisch angelegte Key sofort als „ungenutzt“ da, obwohl sein // Besitzer ihn vielleicht erst morgen einrichtet. Nach 90 Tagen ohne einen // einzigen Aufruf ist dagegen klar: Den braucht niemand. // // Zurückgezogene Keys sind nie „ungenutzt“, denn sie sind ohnehin kein offener // Zugang mehr. // // jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen // Zeitpunkt vorgeben können. // // Abgelaufene Keys sind ebenso wenig „ungenutzt" — auch sie öffnen nichts mehr. func (k APIKey) Unused(jetzt time.Time) bool { return ungenutzt(!k.Valid(jetzt), k.LastUsedAt, k.CreatedAt, jetzt) } // ungenutzt ist die gemeinsame Regel hinter APIKey.Unused und // WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab // als „ungenutzt“ gelten. func ungenutzt(zurueckgezogen bool, zuletztBenutzt, angelegt, jetzt time.Time) bool { if zurueckgezogen { return false } zuletzt := zuletztBenutzt if zuletzt.IsZero() { zuletzt = angelegt } return jetzt.Sub(zuletzt) > UnusedAfter } // Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben. type Entry struct { ID string ProjectID string Slug string Title string Content string Tags []string Source string // SourceHuman oder SourceAgent Author string // Name des verwendeten API-Keys Curated bool // vom Menschen bestätigtes Wissen statt Beobachtung eines Agenten Version int CreatedAt time.Time UpdatedAt time.Time // RestoredFromTrash setzt nur PutEntry: true, wenn der Schreibvorgang // einen Eintrag aus dem Papierkorb zurückgeholt und überschrieben hat. // Aufrufer sagen das dem Schreiber — ein Agent soll merken, dass er einen // gelöschten Eintrag wiederbelebt hat. Beim Lesen ist der Wert immer false. RestoredFromTrash bool } // StaleAfter ist die Zeitspanne ohne Änderung, nach der ein Eintrag als // „möglicherweise veraltet“ gilt (siehe AUSBAUSTUFEN.md 3.5). // // Ein Jahr ist ein Kompromiss: Ein Zugangsverfahren kann schon nach Wochen // überholt sein, eine Grundsatzentscheidung nach Jahren noch stimmen. Eine // Frist je Art des Wissens wäre genauer, ist aber der größere Schritt. Bis // dahin gilt eine Grenze für alle, und sie ist bewusst eher lang: Ein Vermerk, // der an jedem zweiten Eintrag steht, liest bald niemand mehr. const StaleAfter = 365 * 24 * time.Hour // Stale sagt, ob ein Eintrag seit mehr als StaleAfter nicht geändert wurde. // // Das heißt nicht, dass er falsch ist, sondern nur, dass ihn lange niemand // angefasst hat. Deshalb wird er auch nicht versteckt oder schlechter // gewichtet. Wer ihn liest, soll aber wissen, dass er ihn vor dem Verwenden // prüfen sollte. // // Auch geprüfte Einträge (Curated) können veralten. Das Häkchen sagt nur, dass // ein Mensch den Eintrag irgendwann bestätigt hat, nicht wann. func (e Entry) Stale(jetzt time.Time) bool { return StaleSince(e.UpdatedAt, jetzt) } // StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt // kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry). func StaleSince(geaendert, jetzt time.Time) bool { return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter } // StaleCutoff ist der Zeitpunkt, vor dem ein Eintrag zuletzt geändert worden // sein muss, um als veraltet zu gelten — dieselbe Grenze wie StaleSince, nur // als Wert für eine Abfrage. func StaleCutoff(jetzt time.Time) time.Time { return jetzt.Add(-StaleAfter) } // TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite // braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt. type TrashedEntry struct { ID string Slug string Title string Version int DeletedAt time.Time DeletedBy string // Name des Zugangs, der gelöscht hat } // EntryVersion ist eine abgelöste Fassung eines Eintrags — der Stand, der bei // einem Schreibvorgang überschrieben wurde (siehe migrations/004). type EntryVersion struct { ID string EntryID string Version int // Fassungsnummer, die diese Zeile VOR der Ablösung trug Title string Content string Tags []string Author string Source string Curated bool CreatedAt time.Time // Zeitpunkt der Ablösung, nicht der Entstehung } // Herkunft eines Eintrags. const ( SourceHuman = "human" SourceAgent = "agent" ) // AuditEvent ist ein Eintrag im Nachvollziehbarkeitsprotokoll. type AuditEvent struct { ID string Timestamp time.Time ProjectID string APIKeyID string Actor string // Key-Name, "platform-admin" oder "unbekannt" Action string // z.B. "entry_write", "key_created", "auth_failure" EntrySlug string Source string // "web", "mcp" oder "system" IP string Detail string } // ── Hilfsfunktionen für IDs und Zeit ───────────────────────────────────────── // NewID erzeugt einen neuen Primärschlüssel. // // # Warum nicht die Datenbank zählen lassen? // // Ein AUTOINCREMENT wäre bequem, bringt aber drei Nachteile: Beim Umzug auf eine // andere Datenbank müssen die Sequenzen nachgezogen werden, die IDs verraten die // Anzahl der Datensätze, und man kennt die ID erst nach dem INSERT — was das // Anlegen zusammenhängender Datensätze umständlich macht. // // # Aufbau // // Zeitanteil in Basis 36 + 8 zufällige Hex-Zeichen, z.B. "m1x2p3q4-a3f9c1d2". // Der Zeitanteil steht vorne, damit die IDs in ihrer natürlichen Sortierung // zugleich chronologisch sind — praktisch fürs Audit-Log und für Indizes, weil // neue Werte immer hinten angehängt werden statt den Baum zu zerstreuen. func NewID() string { zeit := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36) zufall := make([]byte, 4) if _, err := rand.Read(zufall); err != nil { // crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der // Zeitanteil zur Not aus — er ist auf die Millisekunde genau. return zeit } return zeit + "-" + hex.EncodeToString(zufall) } // timeFormat ist das Format aller Zeitstempel in der Datenbank. // // RFC3339 in UTC, weil es sich als Text korrekt sortieren lässt: "2026-01-02T…" // kommt alphabetisch vor "2026-01-03T…". Damit funktionieren ORDER BY und // Bereichsabfragen ohne Datumsfunktionen der Datenbank — und die bleiben damit // austauschbar. const timeFormat = time.RFC3339Nano // NowString gibt den aktuellen Zeitpunkt im Datenbankformat zurück. func NowString() string { return time.Now().UTC().Format(timeFormat) } // formatTime wandelt einen Zeitpunkt ins Datenbankformat. // Der Nullzeitpunkt wird zum leeren String — so kommt die Anwendung ohne // NULL-Behandlung aus, was den Go-Code deutlich schlanker hält. func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(timeFormat) } // parseTime wandelt einen Datenbankwert zurück in einen Zeitpunkt. // Unlesbare oder leere Werte ergeben den Nullzeitpunkt statt eines Fehlers — // ein kaputter Zeitstempel soll keine ganze Seite scheitern lassen. func parseTime(s string) time.Time { if s == "" { return time.Time{} } t, err := time.Parse(timeFormat, s) if err != nil { return time.Time{} } return t } // isUniqueViolation erkennt, ob ein Fehler von einer verletzten // Eindeutigkeitsbedingung stammt. // // Leider gibt es dafür keinen einheitlichen Weg über verschiedene Datenbanken // hinweg, ohne treiberspezifische Fehlertypen einzubinden. Die Textprüfung ist // unschön, aber sie ist an genau einer Stelle gekapselt und deckt sowohl SQLite // ("UNIQUE constraint failed") als auch PostgreSQL ("duplicate key value") ab. func isUniqueViolation(err error) bool { if err == nil { return false } text := strings.ToLower(err.Error()) return strings.Contains(text, "unique constraint") || strings.Contains(text, "duplicate key") }