186 lines
6.1 KiB
Go
186 lines
6.1 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
|
|
}
|
|
|
|
// Revoked sagt, ob der Key zurückgezogen wurde.
|
|
func (k APIKey) Revoked() bool {
|
|
return !k.RevokedAt.IsZero()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
}
|