Files
nxdns/specs/milestone-8.md
mokhtar c50c6d285a
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s
milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
2026-08-07 17:55:59 +02:00

865 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
> **Placement deviation (milestone 17 ruling 3).** "The Settings page must
> edit them" above names the wrong page. Milestone 9 dropped the obligation
> entirely; milestone 17 restored it as a dedicated **Upstreams** page with
> its own nav entry (`web/src/features/upstreams/`, route `/upstreams`),
> matching the house resource-page pattern that every other collection
> follows. The Settings page keeps the scalar settings keys only. The API
> contract in this ruling is unchanged.
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.