# lethe Personal AI assistant log aggregator. `lethe` is a small, single-binary Go service that ingests turn-level NDJSON from AI assistant collectors (Claude Code, opencode, etc.), stores it in SQLite, and exposes a JSON API for listing and reading sessions. Search and the collector binary live in sibling repos / tasks (`lethe-collector-claude-code`, `lethe-search-and-opencode`); this repo is just the server. ## Purpose - A single private store for all my AI assistant transcripts across hosts and tools. - Per-user data isolation so each authenticated user only sees their own sessions; admins can override with `?owner=`. - Simple deploy: one Go binary, one SQLite file, all schema changes via embedded migrations, `127.0.0.1` bind behind a reverse proxy. ## Quickstart ```bash cp config.example.yaml config.yaml just run # or, for hot reload during development: just air ``` The server reads `config.yaml` by default. Pass `-config ` to override. ## Trust model `lethe` always binds `127.0.0.1`. It is not safe to expose it to the network directly. All authentication assumes a trusted reverse proxy on the same host (e.g. Caddy or Traefik) that terminates TLS and either injects auth headers or relays an OIDC bearer. The implicit assumption is that nothing else on the host binds `127.0.0.1` and forges auth headers. That is the whole trust boundary for path (a) below. There are two independent auth paths, each gated by the `auth.allowed_users` allowlist as a defense-in-depth check. If both are enabled and a request carries both an `Authorization` header and the configured forward-auth header, the bearer is validated first; the header is only consulted if bearer validation fails. ### (a) Forward-auth: reverse proxy + Authelia + `Remote-User` The reverse proxy runs Authelia forward-auth on the lethe vhost. On success Authelia signs the user identity into headers (`Remote-User`, `Remote-Email`, `Remote-Groups`) which the proxy forwards to lethe. `lethe` reads the configured `auth.forward_auth.user_header` (default `Remote-User`), checks the allowlist, and 403s on miss. Sample Caddy snippet (`/etc/caddy/Caddyfile`): ```caddy lethe.example.com { forward_auth http://127.0.0.1:9091 { uri /api/verify?rd=https://auth.example.com copy_headers Remote-User Remote-Email Remote-Groups Remote-Name } reverse_proxy 127.0.0.1:8080 } ``` `lethe` config: ```yaml auth: allowed_users: ["bigbes"] forward_auth: enabled: true user_header: "Remote-User" ``` Sample request (the proxy injects the headers on your behalf — this curl shows what reaches lethe, not what you'd send from a browser): ```bash curl -H 'Remote-User: bigbes' http://127.0.0.1:8080/api/v1/sessions ``` ### (b) OIDC bearer: Authelia issues, lethe validates `lethe` is registered as an OIDC client of Authelia. `lethe` only validates tokens; it never issues them. The bearer must already be obtained out-of-band by whatever client wants to call the API (the collector, scripts, etc.). Sample Authelia client entry (`identity_providers.oidc.clients` in `configuration.yml`): ```yaml identity_providers: oidc: clients: - client_id: lethe client_name: Lethe client_secret: '' public: false authorization_policy: two_factor redirect_uris: - https://lethe.example.com/oauth2/callback scopes: - openid - profile - email userinfo_signing_algorithm: none token_endpoint_auth_method: client_secret_basic ``` `lethe` config (note: only the issuer URL, audience, and the username claim — no client secret on the lethe side, since lethe never starts a flow): ```yaml auth: allowed_users: ["bigbes"] oidc: enabled: true issuer: "https://auth.example.com" audience: "lethe" username_claim: "preferred_username" ``` Sample request: ```bash curl -H "Authorization: Bearer $TOKEN" \ https://lethe.example.com/api/v1/sessions ``` ### Public endpoints `/healthz`, `/readyz`, and `/metrics` are mounted outside the auth middleware so the host can scrape them locally without going through the proxy. Everything under `/api/v1/*` is authed. ## API surface | Method | Path | Auth | Notes | |--------|-----------------------------------------------------|------|-------| | POST | `/api/v1/ingest` | yes | NDJSON body of `TurnEvent` records. Idempotent at the turn level per owner. | | GET | `/api/v1/sessions` | yes | Paginated. Filters: `tool`, `host`, `since`, `until`. Admins may pass `?owner=` or `?owner=*`. | | GET | `/api/v1/sessions/{tool}/{host}/{session_id}` | yes | Full session with turns inline. Admins may pass `?owner=`. | | GET | `/healthz` | no | Liveness. | | GET | `/readyz` | no | Readiness — DB ping etc. | | GET | `/metrics` | no | Prometheus. | The `owner` field is server-derived; the wire format has no `owner`. A non-admin caller passing `?owner=` gets 403. Errors are RFC 7807 `application/problem+json`. ## Production deployment `lethe` binds `127.0.0.1` only. Run it on the same host as a reverse proxy that terminates TLS and injects auth headers (or relays an OIDC bearer). Do not expose the listener to the network directly. When run via the bundled `docker-compose.yml`, the service does not publish a port to the host — it only `expose`s `8080` on the compose network. The reverse proxy reaches lethe through that network. ## Backup The SQLite DB is the only state. Take consistent online backups with `sqlite3 .backup`. Sample cron with 14-day retention: ```cron # /etc/cron.d/lethe-backup # Daily online backup of the lethe SQLite database, 14-day retention. PATH=/usr/bin:/bin 15 03 * * * lethe \ ts=$(date +\%Y\%m\%d-\%H\%M\%S); \ sqlite3 /var/lib/lethe/lethe.db ".backup '/var/backups/lethe/lethe-$ts.db'" && \ find /var/backups/lethe -name 'lethe-*.db' -mtime +14 -delete ``` `/var/backups/lethe` should live on a different volume from the live DB. ## Layout ``` . ├── cmd/lethe/ # main, thin shell ├── internal/ │ └── shared/wire/ # locked NDJSON contract shared with the collector ├── config.example.yaml ├── Justfile ├── .air.toml ├── Dockerfile ├── docker-compose.yml └── .golangci.yml ``` The rest of the layout (`internal/config`, `internal/server`, `internal/domain/...`, `internal/platform/...`, `migrations/`) lands in later phases.