~bigbes/lethe

ref: 3c45b48bd5d0d99f76d2063504adaa7b312b85bc lethe/README.md -rw-r--r-- 6.6 KiB
3c45b48b — Eugene Blikh feat(http): chi server with middleware stack + RFC 7807 problem renderer a month ago

#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

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 <path> 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):

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:

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):

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):

identity_providers:
  oidc:
    clients:
      - client_id: lethe
        client_name: Lethe
        client_secret: '<argon2 hash>'
        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):

auth:
  allowed_users: ["bigbes"]
  oidc:
    enabled: true
    issuer: "https://auth.example.com"
    audience: "lethe"
    username_claim: "preferred_username"

Sample request:

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=<user> or ?owner=*.
GET /api/v1/sessions/{tool}/{host}/{session_id} yes Full session with turns inline. Admins may pass ?owner=<user>.
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 exposes 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:

# /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.