Add --access-log <path> (env ZDDC_ACCESS_LOG). When set, every access-
log record is written as a JSON line to the configured file in
addition to the existing slog.Default() stderr output. Empty (default)
keeps the prior behavior — stderr only.
Rotation via gopkg.in/natefinch/lumberjack.v2:
100 MB per file, 10 backups, 90-day max age, gzip rotated files.
Operator usage (e.g. behind a Caddy/quadlet stack):
zddc-server --access-log /srv/.zddc.d/logs/access.log ...
Architecture:
AccessLogMiddleware now takes an optional *slog.Logger. main.go wires
it via setupAccessAuditLog() which builds a slog.JSONHandler over a
lumberjack rotator. Stderr emission stays via slog.Default(); the
audit logger gets the same fields in line-delimited JSON, the format
every standard log shipper (Vector, Loki, fluentbit, journalbeat)
parses natively.
Tests cover the audit logger receiving the same email/path/status
fields as the stderr stream.
The middleware chain in main.go was:
AccessLogMiddleware ( CORSMiddleware ( ACLMiddleware ( dispatch ) ) )
ACLMiddleware extracts the user email from the configured header and
stores it in the request context via r.WithContext. But Go's context
propagates DOWN the chain (to handlers further in) — not back UP. The
new context-bearing request only exists inside the call to
next.ServeHTTP; once that returns, the outer middleware still has the
ORIGINAL request without the email. So AccessLogMiddleware's
EmailFromContext(r) call (which runs after next.ServeHTTP returns to
log the request) read from the original context and got an empty
string, falling through to "anonymous".
The /.profile/ page worked correctly because it reads the email
directly inside the handler — at that depth the context-bearing
request is the one in scope.
Fix: invert the chain so ACL is OUTERMOST.
ACLMiddleware ( AccessLogMiddleware ( CORSMiddleware ( dispatch ) ) )
Now ACL extracts the email and the new request flows down through
AccessLog (which sees the email-bearing context), CORS, and dispatch.
Add three regression tests in middleware_test.go:
TestAccessLogReadsEmailFromACLContext
The fix: with ACL outer, AccessLog logs email=alice@example.com
when X-Auth-Request-Email is set.
TestAccessLogAnonymousWhenNoEmail
The unchanged path: no header → email=anonymous (correct fallback).
TestAccessLogOuterDoesNotSeeInnerContext
Locks down Go's actual context-propagation behavior. Builds the
INVERTED (buggy) chain and asserts that AccessLog (outer) does NOT
see the email ACL (inner) set. If this ever fails, Go's context
propagation has changed in a way that lets inner-set context flow
upward — which would mean the reordering fix could be reverted.
All zddc-server tests pass via `go test ./...` (run in podman against
golang:1.24-alpine since this dev host doesn't have Go installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>