313 lines
11 KiB
Go
313 lines
11 KiB
Go
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(now time.Time) bool {
|
|
return !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt)
|
|
}
|
|
|
|
// Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen.
|
|
func (k APIKey) Valid(now time.Time) bool {
|
|
return !k.Revoked() && !k.Expired(now)
|
|
}
|
|
|
|
// 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(now time.Time) bool {
|
|
return k.Valid(now) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(now) <= 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.
|
|
//
|
|
// now 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(now time.Time) bool {
|
|
return unused(!k.Valid(now), k.LastUsedAt, k.CreatedAt, now)
|
|
}
|
|
|
|
// unused ist die gemeinsame Regel hinter APIKey.Unused und
|
|
// WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab
|
|
// als „ungenutzt“ gelten.
|
|
func unused(revoked bool, lastUsed, created, now time.Time) bool {
|
|
if revoked {
|
|
return false
|
|
}
|
|
latest := lastUsed
|
|
if latest.IsZero() {
|
|
latest = created
|
|
}
|
|
return now.Sub(latest) > 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(now time.Time) bool {
|
|
return StaleSince(e.UpdatedAt, now)
|
|
}
|
|
|
|
// StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt
|
|
// kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry).
|
|
func StaleSince(changed, now time.Time) bool {
|
|
return !changed.IsZero() && now.Sub(changed) > 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(now time.Time) time.Time {
|
|
return now.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 {
|
|
ts := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36)
|
|
|
|
rng := make([]byte, 4)
|
|
if _, err := rand.Read(rng); err != nil {
|
|
// crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der
|
|
// Zeitanteil zur Not aus — er ist auf die Millisekunde genau.
|
|
return ts
|
|
}
|
|
|
|
return ts + "-" + hex.EncodeToString(rng)
|
|
}
|
|
|
|
// 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")
|
|
}
|