milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+856
View File
@@ -0,0 +1,856 @@
# Milestone 8: web server, REST API, SSE, auth, metrics (PLAN Phase 8, Zig side)
Goal: the complete Zig web layer — HTTP server + router, every REST handler, SSE live query
stream, optional argon2id auth with sessions, API token-bucket rate limiting, `/metrics`,
`/api/health`, OpenAPI served + contract tests, asset embedding + dev-mode disk serving —
running as one more task in app.zig's group.
Ground truth: PLAN.md:610-612 (phase text), :537-550 (endpoints), §3.11/§12.1/§19 (auth),
§10:333 (API limiting), §11.4:455 (SSE precedes persistence), §13.2 (OpenAPI), scratchpad
notes m8-explore-{plan,repo,zig}.md. Load-bearing Zig facts are restated inline; verify
anything else against /home/mokhtar/app/zig tag 0.16.0.
## Rulings (binding)
1. **Sequencing**: this milestone is the Zig web layer end to end. The React SPA (PLAN §3.14,
§14 — ten pages) is milestone 9, built against this milestone's finished, contract-tested
API. Not a scope cut: the asset embedding, dev-mode disk serving, ETag and gzip machinery
all ship NOW and are complete; milestone 9 only swaps the dist content. The embedded dist
in this milestone is a minimal real page (see ruling 24) — machinery is not stubbed.
2. **`POST /api/certs/reload` is Phase 9's**, whole: endpoint, openapi.yaml entry and docs
land together with the DoH/DoT server it reloads. No 501 stub, no dangling contract entry.
3. **`docs/api/` rendering is Phase 10** (the docs phase). Phase 8 serves the yaml.
4. **Server model**: per-connection `std.http.Server` (Server.zig:25) over our own accept
loop, copied from lib/std/Build/WebServer.zig:152-185: listener task in the app group; an
inner `Io.Group` of connection tasks; keep-alive loop on `receiveHead` with
`error.HttpConnectionClosing => return`. Cancel semantics mirror tcp_server's S3 As-built:
on cancellation the inner group is CANCELED, not awaited (a keep-alive client must not
hold shutdown open); on listener close (`SocketNotListening`) it drains.
5. **Bind**: one listener socket on `web.bind:web.port` exactly as configured (default
0.0.0.0:8080). No dual-stack ceremony — milestone 7's parity ruling was about DNS client
identity, not the admin UI. An operator who wants v6 sets `web.bind = "::"` (dual-stack by
Linux default).
6. **`web.enabled = false`** skips the entire subsystem, `/metrics` included. No web task, no
web DB connections.
7. **Limits**: recv buffer 8 KiB (this caps the request head — Server.zig:32); send buffer
4 KiB; request body cap 1 MiB (`Io.Limit`, read via `readerExpectNone`/`allocRemaining`);
64 concurrent connections (accept beyond that: respond 503 and close — never silently
drop). No per-request timeout this milestone (LAN-facing; the cancel path bounds
shutdown); documented in server.zig.
8. **JSON shape**: snake_case field names everywhere (matches settings keys and SQL). Error
envelope `{"error":"<message>"}`. Status codes: 400 validation, 401 unauthenticated,
404 missing, 405 wrong method (with Allow), 409 conflict (duplicate key), 413 body too
large, 429 rate limited (with Retry-After), 500 internal (generic message, detail to log
as warn), 503 over connection cap.
9. **Resource paths** (freezing PLAN:541's ellipses): collections `GET`+`POST` and items
`GET`+`PUT`+`DELETE` by numeric row id for: `/api/groups[/{id}]`,
`/api/blocklists[/{id}]` (the sources table), `/api/rules[/{id}]`,
`/api/local-records[/{id}]`, `/api/forward-zones[/{id}]`, `/api/upstreams[/{id}]`
(PLAN's list omitted upstreams; the Settings page must edit them; new resource, same
pattern). Group-source assignment: `PUT /api/groups/{id}/sources` with `{"source_ids":
[..]}` — idempotent full-set replace. Clients: `GET /api/clients` (ALL rows, materialized
included, each with `hand_edited`), `GET/PUT/DELETE /api/clients/{id}`; PUT sets
`hand_edited=1` and may change name/group; DELETE removes the row (a live client
re-materializes). Client prefixes: `GET/PUT /api/client-prefixes` as a whole-list
resource (tiny table, atomic replace). No POST for clients — creation is by DNS activity
or import (PLAN:540 gives clients no POST deliberately).
10. **Repo layer**: every list row the API serves carries its row id; each mutated resource
gains `getX(db, id)`, `updateX(db, id, item)`, `deleteX(db, id)` (strict: 0 rows touched
→ error.NotFound), written in the house repo idiom with prepared statements. Existing
import-path functions stay frozen.
11. **`GET /api/queries`**: keyset pagination `?limit` (default 100, max 1000) +
`?before=<row id>`, ordered id DESC; filters `domain=` (substring), `client=` (exact),
`blocked=` (bool), `since=`/`until=` (unix seconds). Response `{"queries":[...],
"next_before": <id>|null}`. Row fields: id, ts, domain, client_ip, qtype, blocked,
block_reason, response_time_us, cache_hit, upstream.
12. **Live vs restart**: mutations to rules, blocklists, groups, group-sources, clients and
client-prefixes call `Manager.reload(io)` and take effect live (the handler's next
`acquire` sees the new snapshot). Local records and forward zones ALSO apply live: a new
`src/server/local_tables.zig` RCU holder (acquire/release + swap, mirroring the manager's
pattern at small scale) owns `{records, zones}`; `Handler` reads through it instead of
`*const` fields; the local-records/forward-zones handlers rebuild and swap. Upstream
mutations and everything in `/api/settings` are restart-required. `POST
/api/blocklists/update` = `Manager.refreshAll` then reload, 202 with the status snapshot.
13. **Stats grammar**: `period=1h|24h|7d|30d` (else 400). `/api/stats` returns totals
{queries, blocked, cached, clients (distinct), avg_response_time_us} for the period.
`/api/stats/timeseries` buckets: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h; UTC; each
bucket {ts, queries, blocked, cached}. SQL aggregates over query_log on the web task's
own read connection.
14. **`GET /api/lookup?domain=&group_id=`** (group optional, default group when absent):
normalizes, then reports the full pipeline view: `{domain, group_id, local_records:
bool, forward_zone: <zone>|null, blocked, reason, matched, source_url|null,
safe_search_rewrite: <target>|null}` via `snapshot.evaluate` + records/zones lookups.
Null snapshot → 503 `{"error":"no snapshot loaded"}`.
15. **Pause API**: `GET /api/pause``{"paused": bool, "until": <unix s>|null}` (null while
unpaused OR indefinite — disambiguated by `paused`). `POST /api/pause` body
`{"paused": true, "duration_seconds": <u32, optional>}` or `{"paused": false}`.
16. **`GET/PUT /api/settings`**: the typed config sections that live in settings rows (dns,
blocking, cache, edns, upstream, logging, disk, blocklist_update, safe_search flag home
— read model.toSettings for the exact key list), minus `web.password*` (never
serialized; a PUT carrying `web.password` re-hashes via the import path's argon2id
params and stores only the hash). GET marks every key `restart_required: true` except
none — ALL settings are restart-required this milestone (live behavior comes from the
resource endpoints and pause; a static table in the handler carries the flag so the UI
banner has transport). PUT validates the merged config through `validate.validate`
before writing any row; partial updates allowed.
17. **Auth**: enabled iff `web.password_hash != ""`. Login verifies with
`std.crypto.pwhash.argon2.strVerify(hash, password, .{ .allocator = gpa }, io)`
(argon2.zig:619 — PHC string carries its params). Sessions: in-memory fixed table of 32
(LRU evict), storing SHA-256 of a 32-byte `io.randomSecure` token; lookup compares
digests with `std.crypto.timing_safe.eql([32]u8, ...)` (slices not accepted —
timing_safe.zig:12). Cookie `nxdns_session=<url_safe_no_pad base64>`; `HttpOnly;
SameSite=Lax; Path=/`; `Secure` NOT set (nxdns serves plain HTTP; TLS termination is the
operator's proxy — documented). Expiry `web.session_ttl_hours`. Logout deletes the
session. A `PUT /api/settings` that changes `password_hash` clears every session.
18. **Auth exemptions**: `/api/health`, `/api/version`, `/metrics`, `/api/openapi.yaml`, and
the static assets are always unauthenticated (monitoring endpoints; the SPA shell must
load to show a login form). Everything else 401s without a valid session when auth is
enabled. `/api/auth/login` is necessarily exempt; failed logins count and are rate
limited like any request.
19. **API rate limiting**: token bucket per client IP (NOT the DNS fixed-window limiter —
m6 ruling 8 reserved the bucket shape for the API): capacity and refill
`web.api_rate_limit_per_min` per 60 s, 4096 tracked IPs (same fixed-table idiom as
rate_limiter/tracker). Localhost exemption: NEW config field `web.api_localhost_exempt:
bool = true` (PLAN §10 says "configurable" and §12.1 forgot the field; model + validate
+ settings key + export round trip — settings are kv rows, no migration needed). 429 +
`Retry-After: <s>`. SSE: connect consumes a token AND respects
`web.sse_max_connections_per_ip`; `/metrics` and `/api/health` are limiter-exempt
(Prometheus must never see 429).
20. **SSE**: `GET /api/queries/live`, `text/event-stream` via `respondStreaming(&.{}, ...)`
(EMPTY buffer — `BodyWriter.flush` does not flush the body writer's own buffer,
http.zig:780; empty buffer makes every write go straight to the chunked drain), flush
headers before the first event (test.zig:498 pattern). Fanout: milestone-6 ruling 4
concretized as `src/server/query_sink.zig`: `QuerySink { logger: *Logger, hub: ?*sse.Hub
}` with `log(io, entry)` = transform once, publish to the hub, enqueue to the logger.
Logger gains additive `transformed(entry) Entry` (pure) and `logTransformed(io, entry)`;
existing `log` = both, frozen behavior. `Handler.logger: ?*Logger` becomes `sink:
?*QuerySink` (mechanical rename at the two call sites + app wiring). Hub: fixed 32
subscriber slots, each a 64-entry ring + `std.Io.Event`; publish never blocks (full ring
→ disconnect that subscriber; SSE clients auto-reconnect). Wire format: `retry: 3000`
once, then `event: query` + `data: <JSON, same fields as /api/queries rows>` per entry;
heartbeat comment `: ping` every 15 s from the subscriber task (Event.waitTimeout).
Fanout precedes persistence (PLAN:455) — the sink publishes before enqueue.
21. **`/metrics`**: Prometheus text format 0.0.4, `nxdns_` prefix, `# HELP`/`# TYPE` lines.
Counters: every `Handler.Stats` field (17, individually, `nxdns_dns_<field>_total`),
logger (queries_dropped, rows_written, batches_gated), cache stats (6, under
`Handler.cache_mutex`), DNS limiter stats (under `limiter_mutex`), tracker stats,
retention stats, log-sink stats, `nxdns_blocklist_refreshes_gated_total`. Gauges:
cache len + memory bytes, disk free/db/log bytes, tracked DNS clients, pending tracker
clients, blocklist generation, `nxdns_up 1`. Per-upstream, labeled `{url="..."}`:
up (available), success_rate, consecutive_failures, total_successes/_failures (copy
`Pool.Snapshot` fields while holding the returned count — `last_error` is borrowed,
copy immediately). No timestamps. Auth-exempt, limiter-exempt.
22. **`GET /api/health`**: `{"status":"ok"|"degraded","disk":{"state":...,
"free_bytes":...,"db_bytes":...,"log_bytes":...,"sample_failures":...},
"upstreams":{"available":N,"total":M},"queries_dropped":N,"writer_failed":bool,
"refreshes_gated":N,"snapshot_generation":N|null}`. Degraded iff disk state != ok, or
zero upstreams available, or writer_failed. 200 either way (degraded is data, not an
HTTP failure).
23. **OpenAPI + contract tests**: hand-written `src/web/openapi.yaml`, `@embedFile`d and
served verbatim. No external validator (dependencies are liabilities): the contract IS a
Zig table in the integration test — every route with method, auth requirement, a seeded
request, expected status, and a response-shape struct that `std.json.parseFromSlice`
must accept with `.ignore_unknown_fields = false`. Two drift guards: (a) the router
exposes `pub const routes: []const RouteInfo`; a test asserts every entry's path+method
appears textually in openapi.yaml; (b) the table covers every `routes` entry (count
equality). CI gets one new job step running the existing `-Dintegration` suite (contract
tests live inside it) — no new tooling.
24. **Static assets**: build.zig gains `-Dweb-dist=<path>` (default `web/dist-placeholder/`,
committed, containing a real minimal `index.html` — current status via /api/health
fetch, links to /metrics; plus favicon). A build step copies the dist dir via
`b.addWriteFiles()` + generated `assets.zig` index (the fixtures anonymous-import
pattern, per research notes — @embedFile paths must live inside the module root),
exposing `pub const files: []const File{path, bytes, content_type, etag}`; etag =
comptime hash. Serving: exact path match, `/` → index.html, SPA fallback (unknown
non-/api path → index.html, 200 — TanStack Router needs it in M9), `ETag`/
`If-None-Match` → 304, `content-encoding: gzip` when a sibling `<name>.gz` exists in
the dist AND the request accepts it. No Date/Last-Modified headers (std has no RFC 1123
formatter; ETag is strictly better for immutable embedded content). Dev mode: CLI flag
`nxdns run --web-dev <dir>` serves from disk (no cache headers), bypassing the embed.
25. **Head-string trap**: `request.head.target` is invalidated by body reads
(Server.zig:594/230). The router copies target into a stack buffer before any body
read. Query strings: split on '?', `std.mem.splitScalar` for pairs,
`Uri.percentDecodeInPlace` per value (std has no query iterator; std.Uri does not parse
origin-form targets).
26. **Wiring**: one `web_server.serve` task in the app group, started last, canceled by the
same group.cancel. `WebState` struct (in web/server.zig) of borrowed pointers assembled
by app.serve: handler (stats + mutexes + cache/limiter), pause, tracker, manager, pool,
monitor, logger, retention stats, local_tables, sink/hub, config arena view (web cfg),
its OWN `config.db` + `querylog.db` connections (m7 ruling 21), version string, started
timestamp. Web DB connections open only when `web.enabled`.
27. **No new schema migration**: query_log already carries what /api/queries needs; the
read session verifies existing indexes and reports if a needed index is missing (then
the orchestrator rules on adding migration v3 — do not add one silently).
28. **Client disconnect**: stream writes surface `error.WriteFailed` with the real cause in
`stream_writer.err` (ConnectionResetByPeer / SocketUnconnected=EPIPE — MSG.NOSIGNAL is
set, Threaded.zig:13071, no SIGPIPE masking needed). A failed write ends the connection
task quietly (debug log at most): clients vanishing is normal.
29. **Input sanitation** (PLAN §19): every path/query/body input is length-capped and
validated before SQL (prepared statements everywhere, already house rule); domain
inputs run through matcher.normalize; no secrets ever logged (password, tokens, cookie
values — assert by review, and the login handler logs only the client IP and outcome).
## Sessions
Wave 1 (parallel): W1 query-log reads, W2 repo CRUD, W3 web core, W4 auth+limiter,
W5 sink+SSE hub+logger split.
Wave 2 (after W3, parallel where files allow): W6 read handlers + metrics, W7 mutation
handlers + local_tables + handler.zig sink/local_tables changes.
Wave 3: W8 static assets + build.zig + openapi.yaml + router registration of everything.
Wave 4: W9 app/cli wiring. Wave 5: W10 integration + contract tests + CI.
The orchestrator wires src/tests.zig per wave and rules on anything a session flags.
---
## Session W1: query-log read layer
Owns: `src/storage/repositories/queries_repo.zig` (additive; BatchWriter and friends frozen).
```zig
pub const QueryRow = struct { id: i64, ts: i64, domain: []const u8, client_ip: []const u8,
qtype: ?u16, blocked: bool, block_reason: []const u8, response_time_us: ?i64,
cache_hit: ?bool, upstream: []const u8 };
pub const QueryFilter = struct { limit: u32 = 100, before: ?i64 = null,
domain_substring: ?[]const u8 = null, client: ?[]const u8 = null,
blocked: ?bool = null, since: ?i64 = null, until: ?i64 = null };
/// Rows come back newest-first. Strings are arena-allocated.
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter)
db.Error!std.ArrayList(QueryRow)
pub const StatsTotals = struct { queries: u64, blocked: u64, cached: u64,
distinct_clients: u64, avg_response_time_us: ?i64 };
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals
pub const Bucket = struct { ts: i64, queries: u64, blocked: u64, cached: u64 };
/// Fills `out` with fixed-width buckets covering [since, until); returns the count.
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket)
db.Error!usize
```
Read the real query_log schema first (querylog_schema.zig) — domain interning means a JOIN;
verify what indexes exist and REPORT (ruling 27) if `ts` or the join needs one that is
missing rather than adding a migration. Dynamic WHERE assembly must still use bound
parameters only (build the SQL from fixed fragments, never interpolate values). Cap
`limit` at 1000 in the repo too. Tests: in-memory db seeded via BatchWriter; filters,
keyset paging across a boundary, bucket alignment, empty ranges, substring escaping
(`%`/`_` in domain must not act as wildcards — use ESCAPE).
Acceptance: fmt/ast clean; temp-root (-lc -lsqlite3) green; frozen surface untouched.
### W1 As built
`pub const max_limit: u32 = 1000` exported. WHERE assembly: six comptime fragments copied
into a comptime-sized stack buffer (overflow impossible by construction), one bare `?` per
predicate, bind order = append order; `likePattern` escapes `%`, `_` and `\` with
`ESCAPE '\'`. Ruling 27 discharged: EXPLAIN QUERY PLAN over all six query shapes rides
idx_query_log_client / idx_query_log_ts / rowid — NO migration v3; the time-windowed page's
temp B-tree re-sort and the DISTINCT count's temp B-tree are inherent and cheap at household
volume. `statsTotals` computes the mean as sum/count integer division (db.Stmt has no float
column reader; avg() returns REAL) — exact microseconds, null when no row recorded a time.
NULL `block_reason`/`upstream` arrive as `""` (columnText convention; neither is ever
written as an empty string). **W6 convention, ruled now: `""` serializes as `""`, not JSON
null** — `upstream=""` already means "cache hit" per milestone-7 ruling 20 and clients
branch on `blocked`/`cache_hit`, not on reason presence. `timeseries` returns
`error.Misuse` for bucket_seconds == 0 or an i64 window overflow, returns 0 for an empty
`out` without touching the database, and aligns buckets to `since` — the HANDLER passes a
grid-aligned since per ruling 13. 14 new tests (43 in file).
---
## Session W2: repo CRUD by id
Owns every file in `src/storage/repositories/` EXCEPT queries_repo.zig (W1's), plus nothing
else. Additive only; import-path functions frozen.
For groups, clients (+prefixes), upstreams, sources, rules, local records, forward zones:
id-carrying list variants (or extend existing rows where the shape already has no
consumers outside export — check callers first; export/import must keep compiling
unchanged), `getX(db, id) db.Error!?XRow`, `insertXRow(db, item) db.Error!i64` (returns
id; distinct from frozen import inserts where semantics differ — e.g. clients insert with
hand_edited=1), `updateX(db, id, item) db.Error!void` (0 rows → error.NotFound),
`deleteX(db, id) db.Error!void` (same), `setGroupSources(db, group_id, source_ids)
db.Error!void` (transactional replace), `replaceClientPrefixes(db, items) db.Error!void`.
Respect FK constraints: deleting a group with clients → error.Constraint surfaces as 409 at
the handler; document each. `settings_repo`: `putSetting(db, key, value)` single-key upsert
if absent. Tests per resource: round trip, NotFound on both update and delete, constraint
surfacing, group-sources replace idempotence.
Acceptance: fmt/ast clean; temp-root green; `zig build test` green (export/import tests
prove the frozen surface).
### W2 As built
New shared `crud.zig`: `execStrict` turns zero-rows-touched into `error.NotFound`. Accepted
deviations: `getX(database, gpa, id) db.Error!?XRow` (rows hold heap strings);
`insertClientRow`/`insertRuleRow` take `now_s` (NOT NULL columns, repos take no Io — the
InsertContext.now mirror); client writes split `ClientInput{ip,name,group_id}` (create) vs
`ClientEdit{name,group_id}` (edit) — **ip is not editable**: it is the identity upsertSeen
matches, and ruling 9 promises only name/group on PUT; client_prefixes get list + replace
only (whole-list resource, per-id would be unused generality); write shapes carry group_id
(a missing group surfaces as the FK violation → 409, not an id-map miss); added
`listGroupSourceIds` (the PUT needs a read counterpart; frozen listGroupSources speaks
names). Constraint map for W7: deleting a group with clients → Constraint (clients.group_id
has no ON DELETE; rules/prefixes/group-sources cascade); bad group_id on client/rule writes
→ Constraint; url/ip/zone/name uniques → Constraint; deletes of clients/upstreams/rules/
local-records/forward-zones can only be NotFound. `setGroupSources`: BEGIN IMMEDIATE,
existence check first (empty set must not report success for a missing group), delete +
distinct insert (set semantics dedupe); `replaceClientPrefixes` transactional whole-table,
duplicate prefix REJECTED as Constraint (two rows for one prefix is a contradiction, not a
set). Schema facts: `upstreams.tls_name` comes from migration v2 (config_schema.zig is v1
only!) — the openapi /api/upstreams entry needs the fifth field; `SourceRow` gained
`is_suggested: bool = false` appended last (manager.zig literals compile unchanged, no
column index moved). `putSetting` is a primary-key upsert — partial settings PUT needs no
read-first and fires no constraint. 45 new tests + 3 crud.
---
## Session W3: web core — server, router, http plumbing
Owns: `src/web/server.zig`, `src/web/router.zig`, `src/web/http_util.zig`.
- server.zig: `WebState` (ruling 26 — declare the struct; fields it cannot yet point at get
wired by W9), `pub fn serve(state: *WebState, io: std.Io) std.Io.Cancelable!void` — bind
per ruling 5 (bind failure: warn and return — the DNS side must keep serving; app treats
web bind failure as non-fatal, W9 documents), accept loop per ruling 4 with the Stop
split, connection budget per ruling 7 (503 over cap), per-connection buffers (8 KiB recv
/ 4 KiB send), keep-alive loop, dispatch into router.
- router.zig: `RouteInfo { method: http.Method, pattern: []const u8, auth: enum {open,
session}, handler: *const fn(...) }`; `pub const routes: []const RouteInfo` (ruling 23
depends on it); match = exact segments + one `{id}` numeric capture; 405 with Allow when
the path matches another method; target copied before body reads (ruling 25); query-pair
iterator + percent-decode helpers (in http_util, pure, tested hard: '+', '%2F', truncated
%, overlong values → 400).
- http_util.zig: JSON respond helpers (`std.json.Stringify` streaming into a
`Writer.Allocating` for content-length, or direct respond for small bodies), the error
envelope, request-body reader with the 1 MiB cap (413), cookie parse/format, bearer of
nothing else.
- The connection handler calls: limiter (W4, via a comptime-checkable interface field on
WebState so W3 compiles before W4 lands — define `ApiLimiter` and `Sessions` as W3-owned
INTERFACE structs? NO: simpler, W3 declares the WebState fields as `*auth.Sessions` /
`*auth.ApiLimiter` types and W3 is built AFTER W4's file exists on disk in the same wave
— to keep the wave parallel, W3 instead keeps auth/limiter checks behind two function
pointers on WebState (`check_auth`, `check_limit`) that W9 wires; W3 tests them with test
doubles. This is the one seam where indirection is warranted; document it.)
- Tests: router matching table, 405/404, query decoding, body cap, cookie round trip,
keep-alive across two requests (loopback socket), 503 over cap, cancel-during-idle
keep-alive returns promptly.
Acceptance: fmt/ast clean; temp-root green (may import std only + own files); no
listener/handler file touched.
### W3 As built
Handler signature: `fn(state: *server.WebState, io: std.Io, request: *http_util.Request)
http_util.HandlerError!void`, `HandlerError = {WriteFailed, HttpExpectationFailed,
OutOfMemory}` — domain outcomes are status codes, only those three escape. `Request` folds
the id capture in (`request.id: ?i64`; no separate params type) and carries a per-request
arena. `RouteInfo` gained `rate_limit: enum{counted,exempt} = .counted` (ruling 19's
exemptions as data, not path matching); WebState gained `fallback: ?HandlerFn` (SPA
fallback is not a route entry; unmatched non-/api → fallback, unmatched /api → JSON 404)
and `reload_fn` (W7's ruling-12 seam). routes.zig: W8 fills `pub const table`; router
re-exports as `routes`; WebState.routes defaults to it, so the drift guards read the array
the server matches. W4/W5 landed mid-session, so sessions/limiter/hub/sink are REAL typed
pointers and check_auth/check_limit default to real implementations (`sessionAuth`,
`bucketLimit`; `allowAll`/`neverLimit` exported as test doubles) — a half-wired server
fails closed. serve returns void (cancellation is classified into the Stop enum;
tcp_server precedent). Own validating percent-decoder — std.Uri's copies malformed escapes
through as literal text (Uri.zig:172-192), ruling 29 wants 400; segments split before
decode so %2F cannot forge a boundary. Loopback tests live in web/server_integration_test
.zig (house gating pattern). Accepted tradeoff: over-capacity 503 is written and the
socket closed without draining, so the client may see RST instead — draining would be an
unbounded blocking read on an at-capacity accept loop (documented). RULINGS RECORDED:
W7 may add the `local_tables` field + import to server.zig (two-line exception, W3
consents); Retention.stats is a cross-task data race — W6 owns storage/retention.zig
additively this wave: atomic counters + `snapshotStats` (logger-counters pattern), and
/metrics reads only through it. 39 tests.
**Amendment (post-W9 critical fix)**: `serveConn` normalizes the request head immediately
after `receiveHead`: a body-carrying method (POST/PUT/PATCH) with neither Content-Length
nor Transfer-Encoding gets `head.content_length = 0` before any dispatch or respond.
RFC 9110 §8.6 defines such a request as having an empty body, but std leaves the head
saying "unknown" and `respond` → `discardBody` (http/Server.zig:631) asserts one of the
two is set — so a bare `curl -X POST` panicked the whole process, DNS included. Zero
content-length satisfies every downstream path: `bodyReader` (http.zig:445) goes straight
to `.ready`, `discardRemaining` sees EndOfStream, keep-alive survives, and
`http_util.readBody` yields an empty slice. Regression test in
server_integration_test.zig: raw POST without either header → well-formed response, empty
body observed by the handler, fresh connection answered after, connection_errors 0.
---
## Session W4: auth + API limiter
Owns: `src/web/auth.zig`, `src/web/api_limiter.zig`, plus the `model.zig`/`validate.zig`
additions for `web.api_localhost_exempt` (ruling 19) — model field, validate rule (none
needed beyond type), settings key list, and the export/import round-trip expectations
(check model.toSettings reflection picks it up automatically; add the settings-count test
adjustments).
- auth.zig: `Sessions` fixed 32-slot store per ruling 17 (create → cookie value out;
validate cookie → bool; logout; clearAll; expiry sweep on access; LRU evict), login
verify via argon2 strVerify (io + allocator required — argon2.zig:600), token =
32 bytes randomSecure → url_safe_no_pad; storage = SHA-256 digest; compare
timing_safe.eql([32]u8,...). `authEnabled(cfg) bool`. Pure unit tests with fixed tokens
(seed io.random? no — inject the token bytes via a testable `createWithToken`).
- api_limiter.zig: token bucket per ruling 19, fixed 4096-key table (house idiom), awake
clock, `check(now, key, is_localhost) Result{allowed, retry_after_s}`; localhost exempt
when configured; `sse_connections` per-IP counter with `tryAcquireSse/releaseSse` against
`sse_max_connections_per_ip`. Tests: refill math at boundaries, burst=capacity, exempt
localhost, sse cap acquire/release, eviction.
Acceptance: fmt/ast clean; temp-root green; `zig build test` green (model/validate/export
tests still pass with the new field).
### W4 As built
Sessions: digests only (SHA-256), full-slot scan on validate so work does not depend on
match position; `verifyPassword` returns `Outcome{ok,denied,unavailable}` — `.unavailable`
(broken PHC string, OOM) maps to 500, not 401; nothing secret is ever formatted (warn
prints `@errorName` only). `createWithToken`/`validateAt` are the test seams. Cookie value
is 43 chars url_safe_no_pad; `cookie_attributes = "HttpOnly; SameSite=Lax; Path=/"`.
Limiter deviations, accepted: (1) `check(io, now, addr)` — the limiter holds its OWN
std.Io.Mutex (W3 dispatches connections concurrently; one lock inside beats N outside) and
derives loopback itself via exported `isLoopback`, so callers cannot disagree; (2) refill
counts millionths of a token and advances the clock only by the span converted, so the
truncated remainder survives — at 1 req/min the 60 s boundary is exact (tests at 999/1000
ms); (3) the SSE per-IP cap binds loopback clients too — `api_localhost_exempt` exempts
the RATE, not the fixed hub-slot resource. Full table → unknown addresses allowed +
`untracked` (DNS-limiter precedent); `sweep` drops only full, SSE-free, window-idle
buckets; `retry_after_s` never 0 on refusal. Model ripple: `web.api_localhost_exempt` in
Web + expected_keys + fixture; validate.zig needed no edit (bool; decodeValue already
rejects non-bool text); no count assertion outside W4 files existed. 12 auth + 14 limiter
tests.
---
## Session W5: query sink, SSE hub, logger split
Owns: `src/server/query_sink.zig` (new), `src/web/sse.zig` (new), `src/storage/logger.zig`
(additive split ONLY: `transformed(entry) Entry` pure + `logTransformed(io, entry)`;
existing `log` becomes transform+logTransformed with byte-identical behavior — the
existing tests must pass unchanged), and `src/server/handler.zig` (mechanical: field
`logger: ?*logger_mod.Logger` → `sink: ?*query_sink.QuerySink`, the log call site, tests
updated to construct a sink around their logger).
- query_sink.zig per ruling 20: publish BEFORE enqueue (PLAN:455).
- sse.zig `Hub`: 32 slots × 64-entry Entry rings + Event per subscriber;
`subscribe() ?SubscriberId` / `unsubscribe(id)`; `publish(io, entry)` copies the entry
into every live ring (Entry is self-contained, ~400 B; a full ring marks the subscriber
`overflowed` and sets its event — the subscriber task sees the flag and ends the
response); `wait(id, timeout)` for the heartbeat loop. All under one mutex; publish is
called on the DNS hot path — it must stay allocation-free and short (copy + flag set).
Tests: publish/consume order, overflow disconnect flag, unsubscribe under load,
heartbeat timeout returns empty.
- Handler tests: one added test proving the sink publishes and logs (tiny hub + logger).
Acceptance: fmt/ast clean; temp-root green including ALL existing handler and logger
tests; `zig build test` + `-Dintegration` green (phase7 tests construct Handlers — they
compile against the renamed field; update them, they are in your ownership for THIS
mechanical change only — list every touched line in the report).
### W5 As built
Every Hub method takes `io` (the mutex needs it); all locking is `lockUncancelable`
(milestone-7 tracker precedent: `handle` has no error union). `Hub.init(self) void`
initializes IN PLACE — 32×64 rings ≈ 900 KiB, so **W9 must `gpa.create(Hub)`, never a
stack local**. Read surface ruling 20 left open, as built: `next(io, id) ?Entry` (pop
oldest), `overflowed(io, id) bool` (sticky; pre-overflow entries stay readable — the
subscriber drains then disconnects), `wait(io, id, timeout) Cancelable!Wake` with
`Wake = {ready, timeout}`; `wait` resets the event under the mutex only while the ring is
empty, so a racing publish is never lost, and a spurious futex wake reports `.timeout`
(costs one heartbeat). Publish-before-persist is pinned structurally (enqueue never
blocks, so a clock cannot observe order): the test closes the logger queue and the entry
still reaches the hub while counting `queries_dropped`. W5 also made the minimal app.zig
compile fix (sink around the logger, hub = null, `.sink = &sink`) — **W9: the sink already
exists in app.zig; only the hub and the rest of the wiring are missing.** 11 sse + 4 sink
+ 1 logger-split + 1 handler test; all pre-existing logger/handler tests unchanged.
---
## Session W6: read handlers + metrics + health + version + openapi route
Owns: `src/web/handlers/stats.zig`, `queries.zig`, `lookup.zig`, `upstream_health.zig`,
`health.zig`, `version.zig`, `metrics.zig` (in web/, not handlers/ — PLAN layout :227).
Implement rulings 11, 13, 14, 21, 22; `GET /api/version` = version.zig string + git commit
build option. Every handler is `fn(state, io, request-ish, params) !void` matching W3's
handler signature; JSON via http_util. Metrics: mind the mutex discipline (cache/limiter
stats under the handler's mutexes; pool snapshot copies borrowed strings immediately;
atomics via .monotonic loads). Handlers read the querylog via state's own connection.
Tests: pure formatting tests per handler where the data is injectable (metrics text
golden test, health degraded matrix, stats bucket math via W1 fixtures on an in-memory
db).
Acceptance: fmt/ast clean; temp-root green.
### W6 As built
Every handler is split into a pure core plus a thin `handle`, so the decisions are tested
without an `http.Server.Request`: `metrics.collect`/`metrics.render`, `health.collect`/
`rollup`, `stats.window`/`periodParam`, `queries.parseFilter`/`page`, `lookup.evaluate`/
`body`, `upstream_health.collect`, `version.body`. Entry points for W8's table:
`metrics.handle`, `stats.totals`, `stats.timeseries`, `queries.list`, `lookup.handle`,
`upstream_health.handle`, `health.handle`, `version.handle` — all match `router.HandlerFn`
(proven by a temp-root assignment). `/metrics` and `/api/health` are `auth = .open,
rate_limit = .exempt`; `/api/version` is `.open, .counted`; the other four are `.session,
.counted`. **W8 must add `_ = @import("web/metrics.zig")` and the six handler files to
src/tests.zig** — this session did not wire the orchestrator's file.
Retention (ruled in W3's As built): `Stats` is now the plain snapshot type and the live
counters moved to a private `Counters` of `std.atomic.Value(u64)` in `Retention.counters`;
`snapshotStats()` reads them `.monotonic`; `runOnce` derives its pass number from the
`fetchAdd` result rather than re-reading. Test assertions changed mechanically at
retention.zig lines 192-195, 215, 220, 234, 238-240, 243-244, 258-260, 283-286, 312-313 and
at phase6_integration_test.zig lines 365-368 (`x.stats.f` → `x.snapshotStats().f`) — that
second file is the only consumer outside retention.zig, and it compiles solely under
`-Dintegration`, so the plain suite does not catch a break there. No behavior changed.
Decisions and deviations, all accepted:
- `/metrics` carries the DNS counters as an ARRAY keyed by `Handler.Stats`'s comptime field
list, so a new counter in the handler appears in the exposition with no edit here; the
same `counterGroup` helper walks every plain stats struct. Missing collaborator = the
whole family is omitted (a zero would read as health, an absent series reads as a gap).
- Added beyond ruling 21's list: `nxdns_disk_sample_failures_total` (a failure mode that
ruling 22 already surfaces) and `nxdns_upstream_enabled` (an upstream that is down and
one that is switched off are different operator problems). Logger `writer_failed` is
deliberately NOT a metric: it is in `/api/health` and would need a gauge family of one.
- `metrics.poolSnapshot` is the one place that copies pool health, shared with the health
rollup: cancel protection around `Pool.snapshot` (server.zig's precedent), then every
borrowed string duped into the request arena immediately. Cache/limiter stats are read
under `Handler.cache_mutex`/`limiter_mutex` with `lockUncancelable` — `HandlerError` has
no `Canceled`, so the W5 precedent applies.
- Ruling 13's window: `until` = end of the bucket `now` falls in, `since = until - width ×
count`, both on the absolute epoch grid (every width divides 86 400, asserted at
comptime), so `/api/stats` and `/api/stats/timeseries` cover the identical span — tested
by summing the buckets against the totals. Both bodies also carry `period`, `since` and
`until` (a chart cannot label an axis without them); the timeseries body adds
`bucket_seconds`. **openapi.yaml must document those fields.**
- `/api/queries`: `limit` outside 1..1000 is a 400 rather than a silent clamp, `before` must
be positive, and each bad parameter has its own message. `next_before` is the last row's
id only on a full page. `domain=`/`client=` empty read as absent.
- `/api/lookup` resolves `group_id` through `groupIndexById`; an id no snapshot group has is
a 400 (`unknown group_id`), a missing manager or snapshot is 503 `no snapshot loaded`, and
the domain runs through `name.fromText` + `matcher.normalize` (ruling 29). `source_url`
comes from `sources_repo.getSource` on the config connection keyed by the snapshot's
source row id — `SourceSets` carries the display name, not the URL; an unreadable row
leaves the field null rather than failing the lookup. Records and zones are read through
`state.handler.?.local_tables` (acquire/release bracketed by one defer), NOT a WebState
field, because that pointer already exists and is the same table W7 swaps.
- `/api/upstream/health` reports url, enabled, available, consecutive_failures,
total_successes, total_failures, success_rate, last_error, plus `available`/`total`
rollup counts. No timestamps: they are `awake`-clock stamps and mean nothing to a client.
- `/api/version` adds `zig_version` and `uptime_seconds` (from `WebState.started_unix`) to
the version and commit strings.
- The only logging in this session is one `warn` per failed database read or pool/source
fault, on the 500 path; nothing is logged for a normal request, and no `std.log.err`.
40 new tests (metrics 5, stats 8, queries 9, lookup 7, health 4, upstream_health 3,
version 4); retention's 6 tests unchanged in number and green.
---
## Session W7: mutation handlers + local_tables + pause + settings + auth handlers
Owns: `src/web/handlers/` `groups.zig`, `blocklists.zig`, `rules.zig`, `local.zig`,
`clients.zig`, `settings.zig`, `pause.zig`, `auth.zig` (login/logout), plus
`src/server/local_tables.zig` (new, ruling 12) and the `src/server/handler.zig` edit to
read records/zones through it (second mechanical handler change; coordinate: W5 already
edits handler.zig — W7 runs AFTER W5 lands; sequence inside wave 2).
- local_tables.zig: `LocalTables { lock: std.Io.RwLock, records, zones }` with
`acquire/release` (shared) and `swap(io, new_records, new_zones)` (exclusive; frees the
old under the lock after swap — no reader can hold across queries since acquire/release
brackets each query). Handler: acquire in handle, release on every exit (same single
defer discipline as the snapshot).
- Handlers: rulings 9, 12, 15, 16, 17-18 behaviors; every mutation validates (shared
validators — reuse validate.zig section rules by constructing the candidate row set;
where validate.zig only does whole-config, extract the per-section check it already has
— validate.zig is NOT in your ownership: if extraction is needed, report it and use a
local check this milestone), writes via W2 repos on state's config connection, then
reload/rebuild per ruling 12. blocklists/update → refreshAll + reload, 202 + status.
settings PUT: merge, validate whole config, write keys, clear sessions on hash change.
- Tests: per handler with in-memory dbs and a real Manager where cheap, else assert repo
effects + reload-called flag via a seam (a `reload_fn` pointer on WebState set by W9;
tests inject a counter).
Acceptance: fmt/ast clean; temp-root green; phase7 integration still green.
### W7 As built
Eleven files, 4199 lines, 89 tests (local_tables 5, mutations 6, groups 13, blocklists 8,
rules 7, local 12, clients 9, upstreams 6, pause 7, settings 10, auth 6).
- **The apply/respond split (house pattern for later waves).** `http_util`'s response
helpers need a live `*http.Server.Request`, which a unit test cannot construct. Every
route splits into an `apply*` decision function driven against an in-memory database and
a thin HTTP wrapper.
- **`mutations.zig` is a new shared file, owned by W7.** It holds the `Failure` union, the
constraint map (NotFound → 404, Constraint → 409, validation → 400), the `reload` and
`swapLocalTables` seams, and the `Bench` test fixture.
- **`config_lock` lives on WebState** (ruled post-report): connection tasks share one
config database connection, and `changes()`/`lastInsertRowid()` are connection state that
`execStrict` reads, so writes serialize through `state.config_lock`.
- **Per-row validation**: a minimal valid skeleton config plus the one candidate row runs
the real `validate.validate`; no validator duplicated. Duplicate keys and foreign-key
violations surface from the database as 409, not 400.
- **`upstreams.zig` is W7's** (ruling 9 listed the resource; neither ownership list named
it). It never calls the reload seam, refuses to delete the last enabled upstream (409),
and reports `restart_required: true`.
- **Ruled additions**: `GET /api/settings` returns a derived `web.auth_enabled` flag; the
default group cannot be renamed or deleted (409); a group that still holds clients cannot
be deleted (409).
- **handler.zig**: `records`/`zones` replaced by `local_tables: ?*LocalTables = null`;
`handle` acquires once per query, releases on defer — one query reads one generation of
records, zones and the filter snapshot together. `Context` gained `records`/`zones`.
- **Ruled mechanical ripples**: web/server.zig two-line exception (import + WebState
field, plus the config_lock field above); app.zig minimal fix (`var tables: LocalTables
= .empty` + build calls — **W9 must review**); udp/tcp/resolver integration tests lost
their `empty_records`/`empty_zones` consts and two `bareHandler` initialisers; phase7
test converted to real tables where needed.
- **Routes for W8** (all match W3's signature; id routes read `request.id.?`): groups
`list get create update remove getSources putSources`; blocklists `list get create
update remove refresh` (refresh = POST /api/blocklists/update, 202 + status snapshot);
rules `list get create update remove`; local `listRecords getRecord createRecord
updateRecord removeRecord listZones getZone createZone updateZone removeZone`; clients
`list get update remove listPrefixes putPrefixes` (no create); upstreams `list get
create update remove`; pause `get post`; settings `get put`; auth `login logout`.
---
## Session W8: static assets, build pipeline, openapi.yaml, route table completion
Owns: `src/web/static.zig`, `src/web/openapi.zig`, `src/web/openapi.yaml`,
`web/dist-placeholder/` (index.html + favicon.svg), `build.zig` (the -Dweb-dist option +
asset module generation), and the final `router.zig` route-table registration IF W3 left
registration data-driven (coordinate: W3 owns router.zig — W8 only ADDS the route array
entries file `src/web/routes.zig` if W3 designed it that way; otherwise W8 hands W3's
owner a patch — resolve by making W3 expose `routes` as a separate file owned by W8 from
the start; W3 must read this paragraph).
Ruling 24 in full: asset module via addWriteFiles + generated index; etag comptime hash;
gzip sibling serving; SPA fallback; --web-dev disk serving (cli flag lands in W9 — W8
exposes `serveFromDisk(dir, ...)`). openapi.yaml documents EVERY route in the table with
schemas matching the handlers' JSON (snake_case, error envelope, auth markers, 429/401
responses). Tests: static matching, 304 flow, gzip negotiation, SPA fallback vs /api 404,
placeholder embeds and serves.
Acceptance: fmt/ast clean; `zig build` with default dist green; temp-root green.
### W8 As built
Seven files: static.zig 362, openapi.zig 66, routes.zig 194 (55 routes), openapi.yaml
2255, handlers/live.zig 184 (ruled mid-session — no session owned the SSE HTTP handler),
tools/gen_web_assets.zig 201 (new build tool, W8's), web/dist-placeholder (real page:
fetches /api/health + /api/version, renders status/upstreams/disk). 21 new tests
(static 9, openapi 3, routes 6, live 3).
- **Asset pipeline**: gzip cannot run in the build system itself, so
`tools/gen_web_assets.zig` runs as a build step: dist staged through WriteFiles (Run
hashes only a directory arg's path string; staging bakes the content hash into the
path, so edits invalidate — proven), tool output merged with generated `.gz` siblings
and `assets.zig` into the `web_assets` anonymous import (exe, cross exes, tests).
- **`web_assets` module surface**: `File{path, bytes, content_type, etag}`,
`files: []const File`; root is the generated assets.zig.
- **ETag** computed in the tool, not comptime (same build-time constant; avoids the
comptime interpreter hashing large M9 bundles). SHA-256/128-bit, quoted, strong.
`.gz` siblings are their own `files` entries with their own ETag; direct `*.gz` paths
are not addressable. The tool skips gz when it does not shrink or below 128 bytes.
- **static.zig surface for W9**: `state.fallback = static.fallback` (embedded) or wrap
`static.serveFromDisk(dir, io, request)` in a HandlerFn for `--web-dev` (app.zig owns
the dir storage; no cache headers in dev mode). Traversal guard in `diskRelativePath`.
- **live.zig** (SSE, ruling 20): respondStreaming with empty buffer, `retry: 3000`,
`event: query` frames, 15 s heartbeat (awake clock) via `Hub.wait`, per-IP cap via
`tryAcquireSse`/`releaseSse`, ring overflow ends the stream with a clean chunked
terminator so EventSource reconnects. RULED: route is `.session, .exempt` (overrides
ruling 19's connect-token line; the per-IP SSE cap is the guard, loopback included).
RULED: the SSE payload omits `id` (a live entry precedes persistence); a comptime test
pins the remaining nine field names to `QueryRow`.
- **Route policy**: open = {/metrics, /api/health, /api/version, /api/openapi.yaml,
/api/auth/login}; limiter-exempt = {/metrics, /api/health, /api/queries/live}; logout
is `.session` (ruling 18).
- **openapi.yaml** documents all 55 routes, cookie security scheme, 401 on every session
route, 429 + Retry-After on every counted route, the W6 stats fields (period, since,
until, bucket_seconds), SSE as text/event-stream; no certs/reload. openapi.zig's test
enforces route ⊆ yaml at unit level; W10 still owns the count-equality drift guard (b).
---
## Session W9: app + cli wiring
Owns: `src/app.zig`, `src/cli.zig`, `src/main.zig` (if forced).
WebState assembly (ruling 26) gated on `web.enabled`; two extra DB connections; hub +
sink construction (sink wraps logger always — hub only when web enabled; DNS path cost
without web = one null check); local_tables construction replacing the direct
records/zones locals; web serve task in the group (started last); reload_fn/check seams
wired; `--web-dev <dir>` CLI flag (parseArgs + help text + runCheck untouched); web bind
failure = warn + continue serving DNS (ruling in W3 — confirm and document in app).
Shutdown order unchanged (web task dies with group.cancel; its connection group cancels
per ruling 4). Smoke test yours: boot with web enabled, curl /api/health, /api/version,
/metrics, /, login flow with a password set (import a config with web.password, verify
cookie + 401 without), SIGTERM exit 0. Report the transcript.
Acceptance: fmt/ast clean; zig build; both test suites green; smoke transcript.
### W9 As built
- `cli.Command.run` payload is now `RunArgs { paths: Paths, web_dev: ?[]const u8 }`;
`parseRunArgs` handles `--web-dev DIR` (both spellings); `check` rejects it;
`runCheck` untouched. Anything spawning `app.run` passes `RunArgs`.
- app.zig serve order: zero-guard (`web.api_rate_limit_per_min`/`session_ttl_hours` zero
on an enabled web → `error.BadRateLimit`, exit 2 — collaborators assert nonzero and a
hand-edited database bypasses validate); heap `sse.Hub` via `gpa.create` + in-place
init (W5 rule), gated on `web.enabled`; sink wraps `(&logger, hub)` ALWAYS (hub null
without web = one branch); two web DB connections (openConfigDb + reopenQuerylogDb,
after openQuerylogDb establishes the file), gated; Sessions + ApiLimiter, gated;
WebState assembled after the DNS handler (`reload_fn = app.reloadManager`,
`fallback = static.fallback` or the dev wrapper, version.string, started_unix); web
task added to the group LAST; bind failure = warn + return, DNS keeps serving.
Shutdown order unchanged. `web.enabled = false` → no hub, no web DBs, no
sessions/limiter, no web task.
- `--web-dev` dir lives in a file-scope var in app.zig (`WebState.fallback` is a bare fn
pointer, no closure; one composition root; written once before the task starts).
- W7's `var tables: LocalTables` holder confirmed correct: `WebState.local_tables`
points at the same holder the DNS handler reads, so `swapLocalTables` swaps both.
- Mechanical ripple: phase7 test line 967 `cli.Paths{...}` → `RunArgs{ .paths = ... }`.
- Smoke test passed end to end: import with web.password → boot → 200 on /api/health,
/api/version, /metrics, /, /api/openapi.yaml → 401 without cookie → login sets
HttpOnly/SameSite=Lax cookie → authed read → logout kills the cookie → wrong password
401 → SIGTERM exit 0. No secrets in logs. `--web-dev` smoked: disk edits appear
without restart.
- **Critical finding (fixed post-session in W3's files)**: a bodyless POST/PUT — no
Content-Length, no Transfer-Encoding, exactly `curl -X POST` — hit the
`discardBody` assert in std (Server.zig:631) and panicked the process, DNS included.
RFC 9110 gives such a request an empty body, so the fix normalizes in the request
path before any respond call (see W3 As-built amendment); W10 pins it with a contract
test.
---
## Session W10: integration + contract tests + CI
Owns: `src/web/web_integration_test.zig` (new), `.gitea/workflows/ci.yml` (additive job
steps only).
Contract table per ruling 23 covering every route; auth on/off matrix (seeded
password_hash vs empty); SSE test (connect, cause one query via a real Handler or direct
sink.publish, assert `event: query` frame + heartbeat + cap enforcement via
sse_max_connections_per_ip=1); rate-limit 429 + Retry-After; pagination walk; mutation →
reload observed (snapshot generation bump); pause via API affects a real handler decision;
settings PUT round trip; static + ETag 304; drift guards (a) and (b). CI: ensure the
integration job still covers everything (contract tests ride -Dintegration; add a
`zig build test -Dintegration` step only if missing — read the current yml first).
Acceptance: `zig build test -Dintegration` green with zero err lines; plain suite green.
### W10 As built
web_integration_test.zig, 1406 lines, 14 tests: 11 integration-gated (contract walk over
all 55 routes with strict `ignore_unknown_fields = false` response shapes, auth on, auth
off, 429 + Retry-After, SSE, pagination walk, mutation → reload, pause, settings round
trip, static/ETag 304, bodyless POST) + 3 ungated pure guards (contract-covers-routes,
drift guard a route ⊆ yaml, drift guard b: 55 yaml operations == router.routes.len).
ci.yml UNTOUCHED — its test job already runs `zig build test -Dintegration` (line 25).
- Environment fully real except two seams: real Manager/reload over an in-memory config
DB, real Sessions/ApiLimiter/Hub (heap Hub per W5 rule), seeded querylog via
BatchWriter. The pool transport and fetcher are never invoked —
POST /api/blocklists/update runs in the walk BEFORE any source row exists (hermetic).
- SSE reader de-frames chunked transfer coding (the unbuffered SSE writer emits one frame
as many chunks; std's chunked writer emits each chunk's closing CRLF lazily — reading
separators eagerly deadlocks until the next heartbeat).
- Heartbeat asserted against the real 15 s interval (`live.heartbeat_interval` is not
injectable): the SSE test runs ~15-20 s in a 40 s budget; the integration suite gained
roughly 45 s of wall clock. Accepted rather than weakening the assertion.
- Pause test drives Handler.handle directly with real DNS packets (phase7 queryFor
pattern): blocked → API pause → forwarded (paused_queries bumps) → unpause → blocked.
- Mutation-reload observed as generation 1 → 2 plus the rule live via GET /api/lookup.
- Contract markers asserted by lookup into router.routes (method+pattern → auth and
rate_limit equality, exact one-to-one coverage); response shapes reference the
handlers' pub types where they exist; the strict settings parse also proves
`web.password*` never serializes.
- One expected `warn` line ("web login refused") from the wrong-password case; zero err
lines suite-wide; no secrets logged.
---
## Module layout (new)
src/web/{server,router,http_util,auth,api_limiter,sse,static,openapi,metrics}.zig,
src/web/routes.zig (W8), src/web/handlers/{auth,stats,queries,clients,groups,blocklists,
rules,local,lookup,pause,settings,upstream_health,health,version}.zig,
src/server/{query_sink,local_tables}.zig, src/web/openapi.yaml, web/dist-placeholder/,
src/web/web_integration_test.zig.
## File ownership
W1 queries_repo; W2 other repositories/*; W3 web/{server,router,http_util}; W4
web/{auth,api_limiter} + model/validate additions; W5 server/query_sink, web/sse,
storage/logger (additive), server/handler (sink rename) + phase7 test compile fixes; W6
web/handlers read set + web/metrics; W7 web/handlers mutation set (incl. mutations.zig,
upstreams.zig) + server/local_tables + server/handler (local_tables read path; AFTER W5); W8 web/{static,openapi,routes} +
openapi.yaml + dist-placeholder + build.zig; W9 app/cli/main; W10 its test file + ci.yml.
Orchestrator: src/tests.zig, spec. Within-wave parallel sessions never share a file;
handler.zig is touched by W5 then W7, strictly sequenced.
## Acceptance (milestone complete)
- [ ] Both suites + cross green; fmt clean; zero err-level lines in integration output.
- [ ] Every PLAN:537-550 endpoint except certs/reload (ruling 2) implemented, documented in
openapi.yaml, and contract-tested; auth on/off matrix green.
- [ ] SSE live stream works end to end with per-IP cap; fanout precedes persistence.
- [ ] /metrics scrapes clean (golden test); /api/health rollup correct.
- [ ] W9 smoke transcript: boot, API answers, login round trip, SIGTERM 0.
- [ ] Placeholder UI loads at /; assets ETag/304/gzip machinery proven.
- [ ] Spec As-built synced per session.
## Review (Codex, As built)
Round 1: 6 important + 3 minor, all fixed.
- local.zig: all six mutation paths hold `config_lock` across write AND rebuild+swap, so
LocalTables swaps publish in database-write order (deadlock-checked: no callee takes
the lock).
- settings/auth/server/app: GET /api/settings reads under `config_lock`. New
`auth.LiveHash` (mutex holder on `WebState.live_hash`, generation-checked — see round
2): login copies the hash out under a short lock and verifies OUTSIDE it; settings PUT
dupes the new hash with gpa BEFORE the transaction, installs + clears sessions after
commit; `sessionAuth` gates on `live_hash.enabled()`, so a first password set via PUT
locks routes without restart. Boot hash borrows the config arena (owned=false);
replacements are gpa-owned, freed on the next install or deinit (app.zig defer /
Bench.deinit / Env.destroy).
- mutations.zig `checkUpstream`: candidate validated beside a companion enabled upstream
(URL collision-avoided), so disabled upstreams are creatable. upstreams.zig
`applyUpdate` gained the missing last-enabled guard: disabling the last enabled
upstream is 409 (delete path already had it; create needs none).
- clients.zig: prefixes canonicalized via platform/address.zig (parse zeroes host bits;
RFC 5952 text) before a set-level duplicate check (409, constraint-map ruling) and
whole-list validation through the real validate.validate; canonical text is stored.
- api_limiter.zig: full table fails closed — `bucketLocked` first evicts a bucket that
refills to capacity with sse==0 (behaviorally identical to fresh; sweep's idle window
is churn hysteresis, not correctness), else `check` refuses with retry_after and
`tryAcquireSse` returns false. `untracked` is now a subset of `refused`.
- static.zig: `acceptsGzip` parses every entry per the header grammar (specific gzip
beats wildcard; malformed q = entry unusable). Dev mode realpath-checks containment of
target AND index fallback (no-follow-on-open would miss symlinked intermediate
directories).
- web_integration_test.zig: drift guard (a) requires the method key inside the specific
path's yaml block; a negative test doctors the yaml (method swap between two paths)
and proves the guard bites.
Round 2: 1 important — login could copy the old hash, race a password-change PUT
(install + clearAll), finish argon2 verification, and mint a session with the revoked
password. Fixed via a generation counter on LiveHash: applyLogin verifies the copy
outside any lock, then `confirmSession` checks the generation and inserts the session
digest under LiveHash.mutex (lock order: live_hash → sessions, single direction at every
site); a changed generation denies the login.
Round 3: 2 minors, both fixed. (a) install-then-clearAll had a gap where a legitimate
new-password cookie could be minted and then wiped — `install` is replaced by
`installAndRevoke(io, gpa, sessions, new_hash)`: swap, generation bump, and nested
clearAll in one LiveHash-ordered operation; a confirm either precedes it (wiped) or
follows it (stale generation, denied). (b) confirmSession no longer holds LiveHash.mutex
across entropy/clock work: token (randomSecure, zeroed on exit) and timestamp are
generated before the lock; only the generation check + `createWithToken` digest insert
run under the ordered locks. `Sessions.create` removed (createWithToken is the seam).
Round 4: no findings.
## Anti-requirements
- No React/SPA content (milestone 9). No DoH/DoT server or certs/reload (Phase 9). No
docs rendering (Phase 10). No WebSocket. No HTTP/2, no TLS on the web port. No external
schema validator or yaml parser. No new DB schema migration without an explicit
orchestrator ruling (27). No pause persistence. No per-request timeouts. No changes to
dns/, filter/matcher, cache, or the DNS pipeline order.