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 } // Revoked sagt, ob der Key zurückgezogen wurde. func (k APIKey) Revoked() bool { return !k.RevokedAt.IsZero() } // 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. func (k APIKey) Unused(jetzt time.Time) bool { if k.Revoked() { return false } zuletzt := k.LastUsedAt if zuletzt.IsZero() { zuletzt = k.CreatedAt } 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 } // 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") }