feat(core): add configuration, logging and sqlite persistence

DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent fe0cd4bf6d
commit 88d7d6d2b9
11 files changed
+2426

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
)
// LevelTrace is the custom level below DEBUG
const LevelTrace = slog.Level(-8)
// Names of the supported levels, as printed and as accepted by ParseLogLevel
var levelNames = map[slog.Level]string{
slog.LevelError: "ERROR",
slog.LevelWarn: "WARN",
slog.LevelInfo: "INFO",
slog.LevelDebug: "DEBUG",
LevelTrace: "TRACE",
}
// ParseLogLevel maps a level name (ASCII case-insensitive, exact match) to its slog level
func ParseLogLevel(s string) (slog.Level, error) {
for level, name := range levelNames {
if asciiEqualFold(s, name) {
return level, nil
}
}
return 0, errors.New("unknown log level")
}
// LevelName renders a level using the five supported names, falling back to the slog default
func LevelName(level slog.Level) string {
if name, ok := levelNames[level]; ok {
return name
}
return level.String()
}
// NewLogger builds the text logger writing to w with the given minimum level
func NewLogger(w io.Writer, level slog.Level) *slog.Logger {
return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) != 0 {
return a
}
switch a.Key {
case slog.LevelKey:
// Render the level with the custom names (TRACE instead of DEBUG-4)
if level, ok := a.Value.Any().(slog.Level); ok {
a.Value = slog.StringValue(LevelName(level))
}
case slog.TimeKey:
// Always log timestamps in UTC
if a.Value.Kind() == slog.KindTime {
a.Value = slog.TimeValue(a.Value.Time().UTC())
}
}
return a
},
}))
}
// trace emits a TRACE record
func trace(ctx context.Context, log *slog.Logger, msg string, args ...any) {
log.Log(ctx, LevelTrace, msg, args...)
}
// traceFunc emits a TRACE function-entry record and returns the matching function-exit emitter
// Usage: defer traceFunc(ctx, log, "Name")()
func traceFunc(ctx context.Context, log *slog.Logger, fn string) func() {
trace(ctx, log, "function entry", "func", fn)
return func() {
trace(ctx, log, "function exit", "func", fn)
}
}
// tokenID returns a stable, non-reversible identifier for a token, safe to log
// It is the first 8 hex characters of the SHA-256 digest of the token
func tokenID(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:4])
}
// asciiEqualFold compares two strings case-insensitively over ASCII letters only
// Non-ASCII bytes must match exactly, so Unicode case folding never applies
func asciiEqualFold(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if asciiLower(a[i]) != asciiLower(b[i]) {
return false
}
}
return true
}
// asciiLower lowercases a single ASCII letter
func asciiLower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}