milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
This commit is contained in:
@@ -38,6 +38,7 @@ Query provenance: every logged query becomes exactly explainable — what the po
|
||||
- **`logging.query_log_buffer_max` now accepts 1 to 37449, down from 1 to 1000000.** The queued entry carries every new provenance field by value and is about four times as wide as before — 1792 bytes against 432 — so the meaningful bound is bytes rather than entries. The ceiling is computed at compile time from the width of the entry so that the queue's worst case stays within 64 MiB, and it moves whenever that width does. The default of 10000 is unchanged and costs about 17 MiB. A configuration above the new ceiling is rejected at startup with the ceiling in the message.
|
||||
- **Group and blocklist source names are now capped at 64 bytes.** Both are copied into every query-log row that mentions them, so an unbounded name was an unbounded cost per row. A longer name is rejected as `GroupNameTooLong` or `SourceNameTooLong`.
|
||||
- **The query log returns to SQLite's default checkpoint cadence.** 0.0.8 stretched `wal_autocheckpoint` on every read-write `querylog.db` connection from the 1000-page default to 8192 pages, on the expectation that it would cut about 130 MiB a day of checkpoint writeback on the deployed Pi. Field measurement on that Pi showed no measurable effect on daily disk writes, so all it bought was a roughly five-hour power-loss durability window in place of the default's ~40 minutes. No pragma is issued any more: the cadence is SQLite's 1000 pages, about 4 MiB, and the ~40-minute boundary is back.
|
||||
- **The admin bundle now has a ceiling the build enforces.** `npm run build` fails if `admin/dist/assets` totals more than 800,000 bytes — it is 708,352 today — and prints the largest chunks when it does. The bundle is embedded in the server binary and served to your LAN, so an accidental dependency arriving in it is a regression every other check would have passed. Alongside it the redesign's closure sweep removed the last code the new pages left behind: an unused API client call and type, and the `features/queries` directory renamed to `features/provenance` now that no page lives there. The investigation links that carry a time window out of a query detail are pinned by their own tests, including one that a link's emitted bounds survive the Activity page's validation unchanged. Nothing an operator uses changed.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
|
||||
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/assert-bundle-size.mjs && node scripts/stamp-dist.mjs",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "oxlint src vite.config.ts",
|
||||
"format": "prettier --write .",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// The admin bundle is embedded in the server binary and served to a household
|
||||
// LAN, so an accidental dependency or a stray asset landing in dist is a
|
||||
// regression nobody would otherwise notice: every other gate passes with a
|
||||
// bundle twice this size. One number, total bytes of dist/assets — not per
|
||||
// chunk, not gzipped — because the failure being caught is "something big
|
||||
// arrived", not chunk shape.
|
||||
//
|
||||
// This runs from admin/ as part of `npm run build`, before stamp-dist: a failed
|
||||
// size check must not leave a fresh .src-hash beside an oversized bundle that a
|
||||
// later Zig build would accept as current.
|
||||
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const BUDGET_BYTES = 800_000;
|
||||
|
||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(distDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
console.error(`assert-bundle-size: cannot read admin/dist/assets: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => ({ name: entry.name, bytes: statSync(join(distDir, entry.name)).size }))
|
||||
.sort((a, b) => b.bytes - a.bytes);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error("assert-bundle-size: no files in admin/dist/assets — did the build emit anything?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const total = files.reduce((sum, file) => sum + file.bytes, 0);
|
||||
const format = (bytes) => bytes.toLocaleString("en-US");
|
||||
|
||||
if (total > BUDGET_BYTES) {
|
||||
console.error(
|
||||
`assert-bundle-size: admin/dist/assets is ${format(total)} bytes, over the ${format(BUDGET_BYTES)} byte budget.`,
|
||||
);
|
||||
console.error("Largest chunks:");
|
||||
for (const file of files.slice(0, 5)) console.error(` ${format(file.bytes).padStart(9)} ${file.name}`);
|
||||
console.error("Drop what arrived, or raise the budget in this script with the reason in the changelog.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`admin/dist/assets is ${format(total)} bytes across ${files.length} files, ` +
|
||||
`${format(BUDGET_BYTES - total)} under the ${format(BUDGET_BYTES)} byte budget`,
|
||||
);
|
||||
@@ -5,7 +5,7 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { QueryDetail } from "@/lib/types";
|
||||
import { provenance } from "@/features/queries/provenanceFixture";
|
||||
import { provenance } from "@/features/provenance/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
|
||||
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ import InlineError from "@/lib/InlineError";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeRow } from "@/features/queries/querySummary";
|
||||
import { summarizeRow } from "@/features/provenance/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeEvent } from "@/features/queries/querySummary";
|
||||
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
qclassName,
|
||||
rcodeName,
|
||||
routeKindLabel,
|
||||
} from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
} from "@/features/provenance/provenanceCopy";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import type { ClientNames } from "@/features/clients/clientNames";
|
||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
|
||||
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
||||
|
||||
const noNames: ClientNames = new Map();
|
||||
|
||||
@@ -17,9 +17,9 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import type { RouteKind } from "@/lib/types";
|
||||
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { rcodeShortName } from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import type { QuerySummary } from "@/features/queries/querySummary";
|
||||
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import type { QuerySummary } from "@/features/provenance/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { diagnosticsBounds, relatedBounds, RELATED_WINDOW_SECONDS } from "./relatedLinks";
|
||||
import { queriesFilterOf, validateActivitySearch } from "./search";
|
||||
|
||||
const TS = 1_700_000_000;
|
||||
|
||||
test("an unbounded origin falls back to the window either side of the query", () => {
|
||||
expect(relatedBounds(TS, { since: undefined, until: undefined })).toEqual({
|
||||
since: TS - RELATED_WINDOW_SECONDS,
|
||||
until: TS + RELATED_WINDOW_SECONDS,
|
||||
});
|
||||
});
|
||||
|
||||
test("a bounded origin carries both of its bounds through unchanged", () => {
|
||||
expect(relatedBounds(TS, { since: 1, until: 2 })).toEqual({ since: 1, until: 2 });
|
||||
});
|
||||
|
||||
test("a half-bounded origin keeps its half and falls back on the other", () => {
|
||||
expect(relatedBounds(TS, { since: 1, until: undefined })).toEqual({
|
||||
since: 1,
|
||||
until: TS + RELATED_WINDOW_SECONDS,
|
||||
});
|
||||
expect(relatedBounds(TS, { since: undefined, until: 2 })).toEqual({
|
||||
since: TS - RELATED_WINDOW_SECONDS,
|
||||
until: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("an origin bound of zero is a bound, not a missing one", () => {
|
||||
expect(relatedBounds(TS, { since: 0, until: 0 })).toEqual({ since: 0, until: 0 });
|
||||
});
|
||||
|
||||
test("the diagnostics window is the fixed window either side of the query, never inherited", () => {
|
||||
expect(diagnosticsBounds(TS)).toEqual({ since: TS - RELATED_WINDOW_SECONDS, until: TS + RELATED_WINDOW_SECONDS });
|
||||
expect(diagnosticsBounds(0)).toEqual({ since: -RELATED_WINDOW_SECONDS, until: RELATED_WINDOW_SECONDS });
|
||||
});
|
||||
|
||||
/**
|
||||
* The admin half of the server's `since <= ts < until` window contract
|
||||
* (queries_repo.zig): what a related link emits has to survive the validation
|
||||
* the Activity route puts every search through, or the link would silently open
|
||||
* a wider window than it named. Inclusion at the edges is the server's property
|
||||
* and is tested there; this pins that the bounds arrive intact.
|
||||
*/
|
||||
test("bounds emitted by a related link round-trip through the Activity search to the same filter", () => {
|
||||
const origin = { since: undefined, until: TS + 3_600 };
|
||||
const emitted = { mode: "history", domain: "ads.example.com", ...relatedBounds(TS, origin) };
|
||||
|
||||
const applied = validateActivitySearch(emitted);
|
||||
|
||||
expect(applied.mode).toBe("history");
|
||||
expect(applied.since).toBe(emitted.since);
|
||||
expect(applied.until).toBe(emitted.until);
|
||||
expect(queriesFilterOf(applied)).toEqual({
|
||||
domain: "ads.example.com",
|
||||
since: TS - RELATED_WINDOW_SECONDS,
|
||||
until: TS + 3_600,
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Provenance, QueryRow } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { RING_CAPACITY, mergeGap, pushRow, summaryOf, type LiveRow } from "./ringBuffer";
|
||||
|
||||
function streamed(key: number, ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): LiveRow {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
||||
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
|
||||
|
||||
/**
|
||||
* A row in the live buffer. `key` is a client-side monotonic counter, because
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { summaryOf, type LiveRow } from "./ringBuffer";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
@@ -184,8 +184,6 @@ export const deleteBlocklist = (id: number): Promise<void> => request(`/api/bloc
|
||||
export const listRules = async (): Promise<Rule[]> => (await request<{ rules: Rule[] }>("/api/rules")).rules;
|
||||
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
|
||||
request("/api/rules", { method: "POST", body: input });
|
||||
export const updateRule = (id: number, input: RuleInput): Promise<RuleEcho> =>
|
||||
request(`/api/rules/${id}`, { method: "PUT", body: input });
|
||||
export const deleteRule = (id: number): Promise<void> => request(`/api/rules/${id}`, { method: "DELETE" });
|
||||
|
||||
// Local records
|
||||
|
||||
@@ -148,6 +148,21 @@ export const sample_create_blocklist: BlocklistEcho = {
|
||||
url: "https://lists.example/ads.txt",
|
||||
};
|
||||
|
||||
export const sample_get_blocklist: Blocklist = {
|
||||
checksum: null,
|
||||
domain_count: 0,
|
||||
enabled: false,
|
||||
exception_count: 0,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
name: "ads",
|
||||
skipped_regex_count: 0,
|
||||
skipped_unsupported_count: 0,
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcard_count: 0,
|
||||
};
|
||||
|
||||
export const sample_list_blocklists: { blocklists: Blocklist[] } = {
|
||||
blocklists: [
|
||||
{
|
||||
@@ -210,6 +225,12 @@ export const sample_create_group: Group = {
|
||||
safe_search: false,
|
||||
};
|
||||
|
||||
export const sample_get_group: Group = {
|
||||
id: 0,
|
||||
name: "kids",
|
||||
safe_search: false,
|
||||
};
|
||||
|
||||
export const sample_update_group: Group = {
|
||||
id: 0,
|
||||
name: "teens",
|
||||
@@ -232,6 +253,16 @@ export const sample_create_rule: RuleEcho = {
|
||||
pattern: "ads.example",
|
||||
};
|
||||
|
||||
export const sample_get_rule: Rule = {
|
||||
action: "block",
|
||||
created_at: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "exact",
|
||||
pattern: "ads.example",
|
||||
};
|
||||
|
||||
export const sample_list_rules: { rules: Rule[] } = {
|
||||
rules: [
|
||||
{
|
||||
@@ -274,6 +305,14 @@ export const sample_create_local_record: LocalRecord = {
|
||||
value: "192.168.1.10",
|
||||
};
|
||||
|
||||
export const sample_get_local_record: LocalRecord = {
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.10",
|
||||
};
|
||||
|
||||
export const sample_list_local_records: { local_records: LocalRecord[] } = {
|
||||
local_records: [
|
||||
{
|
||||
@@ -300,6 +339,12 @@ export const sample_create_forward_zone: ForwardZone = {
|
||||
zone: "lan",
|
||||
};
|
||||
|
||||
export const sample_get_forward_zone: ForwardZone = {
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.1:53",
|
||||
zone: "lan",
|
||||
};
|
||||
|
||||
export const sample_list_forward_zones: { forward_zones: ForwardZone[] } = {
|
||||
forward_zones: [
|
||||
{
|
||||
@@ -332,6 +377,18 @@ export const sample_list_clients: { clients: Client[] } = {
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_client: Client = {
|
||||
first_seen: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
hand_edited: false,
|
||||
id: 0,
|
||||
ip: "192.168.1.50",
|
||||
last_seen: 0,
|
||||
learned_name: "",
|
||||
name: "laptop",
|
||||
};
|
||||
|
||||
export const sample_update_client: Client = {
|
||||
first_seen: 0,
|
||||
group: "default",
|
||||
@@ -389,6 +446,14 @@ export const sample_create_upstream: UpstreamEcho = {
|
||||
url: "https://dns2.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_get_upstream: Upstream = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_update_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface LogoutResponse {
|
||||
|
||||
/**
|
||||
* The three closed enums `src/storage/provenance.zig` stores, as values rather
|
||||
* than bare types: the copy maps in `features/queries/provenanceCopy.ts` have to
|
||||
* than bare types: the copy maps in `features/provenance/provenanceCopy.ts` have to
|
||||
* be proven exhaustive at runtime as well as by `tsc`, exactly as
|
||||
* `DIAGNOSTIC_CODES` below.
|
||||
*/
|
||||
@@ -320,7 +320,7 @@ export interface StatsTimeseries {
|
||||
|
||||
/**
|
||||
* One DNS type's share of the window. `qtype` is the numeric code as logged:
|
||||
* naming it is the admin's job (`features/queries/qtype.ts`), and a row whose
|
||||
* naming it is the admin's job (`features/provenance/qtype.ts`), and a row whose
|
||||
* type was never recorded keeps its own `null` group rather than disappearing.
|
||||
*/
|
||||
export interface StatsTypeRow {
|
||||
@@ -396,10 +396,6 @@ export interface GroupInput {
|
||||
safe_search?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupSources {
|
||||
source_ids: number[];
|
||||
}
|
||||
|
||||
export interface Blocklist {
|
||||
id: number;
|
||||
url: string;
|
||||
|
||||
@@ -342,7 +342,7 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
|
||||
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
|
||||
```
|
||||
|
||||
Rebuild `admin/dist` before the binary on every upgrade. The admin interface is embedded at build time, and an old bundle against a new API is a broken settings page. `dist` refuses the `admin/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `admin/dist` in place.
|
||||
Rebuild `admin/dist` before the binary on every upgrade. The admin interface is embedded at build time, and an old bundle against a new API is a broken System page. `dist` refuses the `admin/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `admin/dist` in place.
|
||||
|
||||
The staged payload for each target is under `zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there with that path in place of the extracted one. The version string has to equal `.version` in `build.zig.zon` — `verify-dist` asserts it, so a made-up one builds and then fails verification. What tells your build apart from the published release of the same version is `-Dgit-commit`, which `nxdns version` prints beside the version.
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ Auth `open` means no session is required; `session` means a valid session cookie
|
||||
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
|
||||
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
|
||||
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
|
||||
| GET | `/api/queries` | session | counted | read | Query log page |
|
||||
| GET | `/api/queries` | session | counted | read | Query log rows for the Activity page |
|
||||
| GET | `/api/queries/{id}` | session | counted | read | One query, fully explained |
|
||||
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | read | Totals for a period |
|
||||
|
||||
@@ -147,9 +147,9 @@ The query-log writer commits one transaction per interval instead of one per que
|
||||
What it costs:
|
||||
|
||||
- **Crash-loss window.** A process that dies takes roughly `interval` seconds of query history with it. That is the normal case, not a guaranteed maximum: a batch the disk monitor is holding back (free space below the critical threshold) or one waiting on a database write lock can be considerably older when the process dies. Power loss can additionally lose recent committed transactions, because `querylog.db` runs with WAL and `synchronous=NORMAL` — that was already true at any interval, and setting `0` does not buy per-query durability. Query history is the least valuable data on this box: nothing else depends on it, and it is deleted by retention anyway.
|
||||
- **Staleness.** Every read backed by the query log — the query-log page, the Overview totals, the timeseries — lags about `interval` seconds behind, and further behind while writes are gated or slow. The live view does not lag: it is fed from the SSE hub before the queue, so queries appear there the moment they are answered.
|
||||
- **Staleness.** Every read backed by the query log — the Activity page's History tab, the Overview totals, the timeseries — lags about `interval` seconds behind, and further behind while writes are gated or slow. Activity's Live tab does not lag: it is fed from the SSE hub before the queue, so queries appear there the moment they are answered.
|
||||
|
||||
`0` means "do not wait": the writer commits the entry that woke it together with whatever is already queued, up to 100 rows. Use it when you want the query-log page to be current to the second and you do not care what that costs the disk.
|
||||
`0` means "do not wait": the writer commits the entry that woke it together with whatever is already queued, up to 100 rows. Use it when you want Activity's History tab to be current to the second and you do not care what that costs the disk.
|
||||
|
||||
Two things do not change with the interval: a batch is capped at 100 rows, so a burst is committed as soon as it fills one rather than waiting out the window, and shutdown writes what the writer is holding instead of waiting for the interval to end.
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ One thing to know before you try other names: an entry in a hosts list blocks ex
|
||||
|
||||
## 12. Open the web interface
|
||||
|
||||
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application you built in step 1, served out of the binary. The Overview shows query and block counts, and the Blocklists page shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
|
||||
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application you built in step 1, served out of the binary. The Overview shows query and block counts, and the Protection page's Sources tab shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
|
||||
|
||||
## 13. Stop it
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Milestone 33: contract closure
|
||||
|
||||
Redesign step 6 of specs/ui-redesign.md (build-sequence step 6): the closure sweep. Remove what the redesign obsoleted, close the contract-sample gaps, add the cross-surface acceptance tests, wire a byte-budget gate, and fix the stale doc references. No behavior changes, no new dependencies, no new endpoints. This milestone ends the redesign; a release cut follows it.
|
||||
|
||||
Grounded in a full-tree inventory (2026-08-22); the m32 deletion left almost nothing orphaned, so the sweep is small and the acceptance tests are the substance.
|
||||
|
||||
## Sessions
|
||||
|
||||
S1 (Zig + contract surfaces + docs) and S2 (admin sweep + link tests + byte budget) run in parallel — no shared files. S1 owns `admin/src/lib/contractSamples.gen.ts` (regeneration) and nothing else under `admin/`; S2 does not touch that file. Neither session runs the admin typecheck as its own gate — `tsc -b` reads the whole admin tree and writes `.tsbuildinfo`, so it cannot run against a tree the other session is editing. The orchestrator runs tsc, vitest, and the full build once after both sessions land (see milestone acceptance).
|
||||
|
||||
---
|
||||
|
||||
## Session S1: Zig closure, contract samples, file-authority enumeration, docs
|
||||
|
||||
### S1.1 Contract-sample gaps
|
||||
|
||||
`contract_sample_walk` (web_integration_test.zig:3707) leaves exactly **seven** successful single-resource GETs unsampled: `/api/groups/{id}`, `/api/blocklists/{id}`, `/api/rules/{id}`, `/api/local-records/{id}`, `/api/forward-zones/{id}`, `/api/clients/{id}`, `/api/upstreams/{id}` (`/api/queries/{id}`, `/api/diagnostics/{id}`, `/api/groups/{id}/sources` are already sampled). Add the seven, each inserted directly after the walk's existing create of that resource (clients have no POST — place the client sample after the existing client list sample) — `seedConfig` and the walk already supply every needed row (client and upstream from seedConfig, group 1 from schema creation, the rest created mid-walk); do not add new seed state, which would shift unrelated goldens. Use the existing `ts_type` conventions so `writeSampleImports` stays correct. Excluded with stated reasons in a comment beside the walk: `/metrics` (Prometheus text, not a JSON contract), `/api/openapi.yaml` (served verbatim, drift-tested elsewhere), `/api/queries/live` (SSE stream, not byte-sampleable). Regenerate `admin/src/lib/contractSamples.gen.ts` with the documented command. S1 does not run the admin typecheck (see §Sessions); if a new sample exposes a server/types mismatch, that is a server bug for S1 to fix — types.ts belongs to S2.
|
||||
|
||||
### S1.2 File-authority enumeration
|
||||
|
||||
Replace the 4-route spot check (`fileModeClasses`, web_integration_test.zig:1177-1210) with an enumeration: the test iterates **every** route whose `policy == .config_write` from `router.routes` (22 today) and asserts each returns the 403 managed-file body in file mode. A valid body cannot be built generically per method — the existing `contract` table already carries a valid concrete target and body for every route and is drift-checked against `router.routes`; reuse those per-route cases (or extend that table with what the 403 walk needs) so the 403 is provably the router's, not a 400. The test asserts its case count equals the table's `config_write` count, so a future `config_write` route cannot ship unenumerated. The one per-handler exception (clients.zig declared-row read) keeps its existing dedicated test.
|
||||
|
||||
### S1.3 Zig visibility sweep
|
||||
|
||||
Un-`pub` the symbols with no external references (inventory list: `api_limiter.isLoopback`, `http_util.decodeInPlace`/`queryPairs`, `router.formatAllow`, `server.sessionAuth`/`bucketLimit`, `static.acceptsGzip`/`etagMatches`/`diskRelativePath`, `stats.periodParam`, `health.queryHistoryState`/`diskState`/`diagnosticsUnavailable`, `live.writeEvent`, and the `apply*` families in handlers/{blocklists,clients,groups,local,settings}.zig). Known corrections: `auth.applyLogin` is referenced from settings.zig tests — it stays `pub`; `mutations.checkClientIp` has no production caller at all (only an in-file test) — **delete** the function and its test, don't just un-export dead code. In-file tests keep access; verify every symbol before touching it and report any other inventory errors.
|
||||
|
||||
### S1.4 OpenAPI + docs
|
||||
|
||||
- The `Provenance` schema (openapi.yaml:2169) is referenced by no path, and there is no honest place to wire it: `/api/queries/{id}` already refs `QueryDetail`, and `/api/queries/live` is an SSE byte stream whose response schema must stay `type: string` — a `$ref` there would falsely document the response as one JSON object. **Delete the schema.** Before deleting, compare its field documentation against `QueryDetail` and the live endpoint's description; fold any information that exists only in `Provenance` into the live endpoint's prose description (which is where the SSE event payload is documented). Drift tests stay green.
|
||||
- Stale page references: `docs/tutorial/first-run.md:214` ("Blocklists page" → the Protection page's Sources tab), `docs/reference/configuration.md:150` ("query-log page"/"live view" → Activity history/live), `docs/reference/api.md:109` ("Query log page" → the Activity surface), `docs/how-to/upgrade.md:345` ("settings page" → the System page). `docs/explanation/performance-and-testing.md:63` stays — historical anecdote about a page that existed then.
|
||||
|
||||
### S1.5 Acceptance (S1)
|
||||
|
||||
- [ ] `zig build test` and `-Dintegration` green; contract byte-compare green; sample count grew by exactly seven.
|
||||
- [ ] The enumeration test covers all `config_write` routes and pins the count.
|
||||
- [ ] openapi drift + docs drift green.
|
||||
|
||||
---
|
||||
|
||||
## Session S2: admin sweep, cross-surface link tests, byte budget, a11y pins
|
||||
|
||||
### S2.1 Dead-code removals
|
||||
|
||||
- `api.ts` `updateRule` (:187) deleted — no edit affordance exists and none is being added; the server route stays (API completeness is a server contract, the admin client only carries what the UI uses).
|
||||
- `types.ts` `GroupSources` (:399) deleted.
|
||||
- `queryKeys` stays exported — it has real external consumers (`features/pause/protection.ts`, plus tests/fixtures in `PauseControl.test.tsx`, `SystemPage.test.tsx`, `features/clients/testFixtures.tsx`). No change.
|
||||
- `admin/src/features/queries/` renamed to `admin/src/features/provenance/` — no page lives there since m29; the four helper modules (provenanceCopy, qtype, querySummary, provenanceFixture) keep their names, importers updated, and the two prose references to `features/queries/...` in `admin/src/lib/types.ts` comments updated too (the grep gate covers them). Pure rename, no logic edits.
|
||||
|
||||
### S2.2 Cross-surface link acceptance tests
|
||||
|
||||
Most emitters are already pinned: `ActivityDetailPage.test.tsx` (RelatedActions domain/client bounds and half-bounded fallback), `OverviewPage.test.tsx` (both stat-tile links), `ClientDetailPage.test.tsx` (24 h link), `HealthStrip.test.tsx` and `DiagnosticDetailPage.test.tsx` (configuration links). Do not duplicate any of them. Add only the two genuinely missing pieces:
|
||||
|
||||
- Unit tests directly on `relatedBounds` and `diagnosticsBounds` (`features/activity/relatedLinks.ts`) pinning the bound arithmetic in isolation (origin-bound fallback ±300 s per bound) — today it is only pinned through component renders. Half-open inclusion is a server-side property; do not try to unit-test it here (that module performs no inclusion check).
|
||||
- One agreement test: an object like `{ mode: "history", domain, ...relatedBounds(origin) }` passed to `validateActivitySearch` (`features/activity/search.ts`) round-trips to the same applied filter — the emitted `since`/`until` are accepted as safe integers and land as the applied window. This is the admin half of the server's `since <= ts < until` contract test (queries_repo.zig:1592). No router mount needed.
|
||||
- The session report maps each emitter to the test that pins it (existing or new).
|
||||
|
||||
`LiveActivity`'s recovered-row detail link intentionally carries the live origin (`mode=live`, no bounds) — changing it to a bounded history link would be a behavior change and is out of scope for this milestone.
|
||||
|
||||
### S2.3 Byte budget
|
||||
|
||||
New `admin/scripts/assert-bundle-size.mjs`, wired into `npm run build` **before** `stamp-dist` (a failed size check must not leave a fresh `.src-hash` beside an oversized bundle that a later Zig build would accept as valid): sums `admin/dist/assets/*` and fails above the budget. No workflow edit — the CI and release jobs already run `npm run build`, so the gate rides along. Budget: **800,000 bytes** (current total 708,352 — ~13% headroom). One number, total bytes, no per-chunk budgets, no gzip modeling — the gate exists to catch an accidental dependency or asset landing in the bundle, not to micro-manage chunks. The script prints the total and the top five chunks on failure.
|
||||
|
||||
### S2.4 Accessibility pins
|
||||
|
||||
No new tooling (no-new-deps). Verify the spec's explicit requirements are pinned and add only what is missing: donut SVGs `aria-hidden` + `focusable="false"` with the visible legend and visually hidden table as the accessible surface (m30 tests — cite or add), the empty-window text, and the activity surface's existing role/aria coverage (m29 — cite). The session report lists, for each spec accessibility clause (ui-redesign.md:60, :287), the test that pins it.
|
||||
|
||||
### S2.5 Step-6 coverage ledger
|
||||
|
||||
ui-redesign step 6 also names query-log-recreation and active-event-recovery acceptance coverage. Both have substantial existing tests; do not write new ones unless the ledger finds a named behavior with no pin. The session report maps each step-6 acceptance clause to its test (file + test name), same format as the accessibility ledger — that map is what lets the milestone claim step 6 closed.
|
||||
|
||||
### S2.6 Acceptance (S2)
|
||||
|
||||
- [ ] vitest, oxlint, prettier clean; `npm run build` green including the new size gate (tsc and the Zig dist build run post-merge by the orchestrator — see §Sessions).
|
||||
- [ ] `git grep -n "features/queries"` empty outside CHANGELOG/spec history.
|
||||
- [ ] The S2.2 tests pass; the round-trip agreement test exists.
|
||||
- [ ] CHANGELOG.md Unreleased entry for the milestone (S2 owns it): closure sweep, new acceptance tests, bundle-size gate.
|
||||
|
||||
---
|
||||
|
||||
## File ownership
|
||||
|
||||
S1: `src/**`, `docs/**`, `src/web/openapi.yaml`, `admin/src/lib/contractSamples.gen.ts` (regeneration only). S2: `admin/**` except `contractSamples.gen.ts`, plus `CHANGELOG.md`. No workflow files change. Parallel — the sets are disjoint.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No behavior changes; no new endpoints; no new dependencies; no a11y tooling.
|
||||
- No per-chunk or gzip budgets; one total-bytes number.
|
||||
- No admin affordances added to justify keeping dead client code (the rule-edit UI is out of scope).
|
||||
- No rewriting of existing passing tests unrelated to this spec's tasks (the spec-required removals — `fileModeClasses`, the `checkClientIp` test, the `Provenance` drift assertion — and the rename's import updates are the whole allowance).
|
||||
|
||||
## Implementation notes (post-build sync)
|
||||
|
||||
- The `apply*` sweep also covered `rules.zig`, `upstreams.zig`, `auth.applyLogout`, and `pause.apply` — the S1.3 list missed them; all verified reference-free. `certs.applyReload` stays `pub` (external test caller).
|
||||
- `http_util.decodeInPlace`/`queryPairs` stay `pub`: `tests/fuzz/http_util_fuzz.zig` imports the module (build.zig:154) — the inventory only scanned `src/`.
|
||||
- The drift guard's negative control (`NullableObject`) was retargeted at `QueryDetail` plus an added `id` field rather than deleted, keeping the proof that the guard sees through a `$ref`.
|
||||
- `configuration.md` had two stale "query-log page" occurrences in the same section; both fixed.
|
||||
- `fileModeClasses` became `fileModeConfigWrites`; it replays the `contract` table's real target+body per `config_write` route and asserts case count == `router.routes` count (22 both sides).
|
||||
- S2.4/S2.5 found every accessibility and step-6 clause already pinned; the only new tests are the 18 assertions in `relatedLinks.test.ts` (all mutation-checked).
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] Both sessions' gates green; post-merge the orchestrator runs `(cd admin && npm run typecheck)`, the full admin gate set, `zig build -Dadmin-dist=admin/dist`, and `zig build test -Dintegration` — all green.
|
||||
- [ ] The step-6 coverage ledger (S2.5) and accessibility ledger (S2.4) are complete.
|
||||
- [ ] CHANGELOG updated. This closes specs/ui-redesign.md's build sequence; the release cut follows as its own step (owner-approved).
|
||||
@@ -320,7 +320,7 @@ fn refilled(bucket: Bucket, now: std.Io.Timestamp, capacity: u64) Refill {
|
||||
/// 127.0.0.0/8 and ::1, the addresses a request from the box itself carries.
|
||||
/// An IPv4-mapped loopback address has already normalized to `.ip4` by the time
|
||||
/// a `NetAddress` exists (`address.zig:51`).
|
||||
pub fn isLoopback(addr: address.NetAddress) bool {
|
||||
fn isLoopback(addr: address.NetAddress) bool {
|
||||
return switch (addr) {
|
||||
.ip4 => |b| b[0] == 127,
|
||||
.ip6 => |b| std.mem.eql(u8, &b, &[_]u8{0} ** 15 ++ [_]u8{1}),
|
||||
|
||||
@@ -91,7 +91,7 @@ fn confirmLogin(
|
||||
|
||||
/// Ends the session the cookie names. An unknown cookie is not an error: the
|
||||
/// point of logging out is to end up logged out, which is where it already is.
|
||||
pub fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool {
|
||||
fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool {
|
||||
const sessions = state.sessions orelse return false;
|
||||
const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false;
|
||||
return sessions.logout(io, value);
|
||||
|
||||
@@ -85,7 +85,7 @@ pub const StatusView = struct {
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -103,7 +103,7 @@ pub fn applyCreate(
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -121,7 +121,7 @@ pub fn applyUpdate(
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -158,7 +158,7 @@ fn pruneFiles(state: *server.WebState, io: std.Io) void {
|
||||
/// `refreshAll` already ends in the manager's own reload; the seam is called
|
||||
/// too, because it is how the composition root learns that a change landed and
|
||||
/// the only reload a test can observe.
|
||||
pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.SourceStatus) union(enum) {
|
||||
fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.SourceStatus) union(enum) {
|
||||
statuses: usize,
|
||||
fail: Failure,
|
||||
} {
|
||||
|
||||
@@ -56,7 +56,7 @@ const PrefixesBody = struct {
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyUpdate(
|
||||
fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
id: i64,
|
||||
@@ -72,7 +72,7 @@ pub fn applyUpdate(
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -83,7 +83,7 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyReplacePrefixes(
|
||||
fn applyReplacePrefixes(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
|
||||
@@ -48,7 +48,7 @@ const Created = union(enum) { id: i64, fail: Failure };
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -68,7 +68,7 @@ pub fn applyCreate(
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -105,7 +105,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -133,7 +133,7 @@ fn deleteLocked(database: *db.Db, arena: Allocator, id: i64) ?Failure {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn applySetSources(
|
||||
fn applySetSources(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
id: i64,
|
||||
|
||||
@@ -150,7 +150,7 @@ pub fn protection(input: Input) Protection {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn queryHistoryState(input: Input) []const u8 {
|
||||
fn queryHistoryState(input: Input) []const u8 {
|
||||
if (input.writer_failed) return query_history_failed;
|
||||
if (input.gate_episode == .losing) return query_history_losing;
|
||||
return query_history_recording;
|
||||
@@ -159,13 +159,13 @@ pub fn queryHistoryState(input: Input) []const u8 {
|
||||
/// The operational log is not recording — either the store never opened or its
|
||||
/// writes are failing. Both mean the same thing to an operator: the record of
|
||||
/// what went wrong is not being kept.
|
||||
pub fn diagnosticsUnavailable(input: Input) bool {
|
||||
fn diagnosticsUnavailable(input: Input) bool {
|
||||
return !input.diagnostics_present or input.diagnostics_write_failed;
|
||||
}
|
||||
|
||||
/// `warn` reads as a log level rather than as a quantity of disk. The monitor
|
||||
/// keeps its own name; the wire says what an operator sees on the page.
|
||||
pub fn diskState(state: disk_monitor.State) []const u8 {
|
||||
fn diskState(state: disk_monitor.State) []const u8 {
|
||||
return switch (state) {
|
||||
.ok => disk_ok,
|
||||
.warn => disk_low,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn view(entry: *const sse.Entry) EventView {
|
||||
|
||||
/// One `event: query` frame. JSON never contains a raw newline, so the whole
|
||||
/// payload is a single `data:` line.
|
||||
pub fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void {
|
||||
fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void {
|
||||
try w.writeAll("event: query\ndata: ");
|
||||
var stringify: std.json.Stringify = .{ .writer = w };
|
||||
try stringify.write(view(entry));
|
||||
|
||||
@@ -88,7 +88,7 @@ fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@im
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyCreateRecord(
|
||||
fn applyCreateRecord(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -106,7 +106,7 @@ pub fn applyCreateRecord(
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdateRecord(
|
||||
fn applyUpdateRecord(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -124,7 +124,7 @@ pub fn applyUpdateRecord(
|
||||
return publish(state, io, arena, database);
|
||||
}
|
||||
|
||||
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
@@ -139,7 +139,7 @@ pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator,
|
||||
// forward zones: decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreateZone(
|
||||
fn applyCreateZone(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -157,7 +157,7 @@ pub fn applyCreateZone(
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdateZone(
|
||||
fn applyUpdateZone(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -175,7 +175,7 @@ pub fn applyUpdateZone(
|
||||
return publish(state, io, arena, database);
|
||||
}
|
||||
|
||||
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
|
||||
@@ -390,12 +390,6 @@ pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfM
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkClientIp(arena: Allocator, ip: []const u8) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.clients = &.{.{ .ip = ip, .group = skeleton_group }};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }};
|
||||
@@ -625,7 +619,6 @@ test "a valid candidate row reports no problem" {
|
||||
try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
|
||||
);
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkClientIp(arena, "192.168.1.10"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default"));
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn view(pause: *const pause_mod.Pause, now_s: i64) View {
|
||||
return .{ .paused = true, .until = until };
|
||||
}
|
||||
|
||||
pub fn apply(
|
||||
fn apply(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
body: Body,
|
||||
|
||||
@@ -54,7 +54,7 @@ fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -74,7 +74,7 @@ pub fn applyCreate(
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -94,7 +94,7 @@ pub fn applyUpdate(
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
|
||||
@@ -309,7 +309,7 @@ pub fn view(cfg: model.Config) View {
|
||||
|
||||
/// Reads, merges, validates, writes, and — when the password changed — ends
|
||||
/// every session. Returns the configuration as it now stands.
|
||||
pub fn applyPut(
|
||||
fn applyPut(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
|
||||
@@ -385,7 +385,7 @@ pub const PeriodError = error{BadPeriod};
|
||||
|
||||
/// An absent `period` is the default; anything else it cannot read is a 400,
|
||||
/// never a silent fallback — a typo must not return a window nobody asked for.
|
||||
pub fn periodParam(query: []const u8) PeriodError!Period {
|
||||
fn periodParam(query: []const u8) PeriodError!Period {
|
||||
var buf: [8]u8 = undefined;
|
||||
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
|
||||
const text = found orelse return default_period;
|
||||
|
||||
@@ -38,7 +38,7 @@ const Created = union(enum) { id: i64, fail: Failure };
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -59,7 +59,7 @@ pub fn applyCreate(
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
@@ -94,7 +94,7 @@ pub fn applyUpdate(
|
||||
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
|
||||
/// answers nothing, and `validate.validate` refuses that configuration at
|
||||
/// startup — so allowing it here would only produce a box that will not boot.
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
|
||||
+9
-25
@@ -266,9 +266,12 @@ paths:
|
||||
summary: Live query stream (server-sent events)
|
||||
description: |
|
||||
`text/event-stream`. The stream opens with `retry: 3000`, then sends
|
||||
one `event: query` frame per resolved query whose `data:` line is a
|
||||
`Provenance` object — the body of `/api/queries/{id}` without its `id`,
|
||||
which does not exist yet because the entry precedes its own insert.
|
||||
one `event: query` frame per resolved query. The `data:` line is one
|
||||
query fully explained, in the order a query meets the pipeline: the six
|
||||
objects `QueryDetail` documents — `request`, `group`, `policy`,
|
||||
`rewrites`, `route`, `response` — all of them required, and without
|
||||
that schema's `id`, which does not exist yet because the entry precedes
|
||||
its own insert.
|
||||
A `: ping` comment goes out every 15 seconds.
|
||||
A client that falls more than 64 events behind is disconnected and
|
||||
should re-sync via `/api/queries` after reconnecting. Connections
|
||||
@@ -2166,31 +2169,12 @@ components:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
Provenance:
|
||||
type: object
|
||||
description: |
|
||||
One query, fully explained, in the order a query meets the pipeline. The
|
||||
`data:` payload of a live-stream `event: query` frame is exactly this.
|
||||
required: [request, group, policy, rewrites, route, response]
|
||||
properties:
|
||||
request:
|
||||
$ref: "#/components/schemas/ProvenanceRequest"
|
||||
group:
|
||||
$ref: "#/components/schemas/ProvenanceGroup"
|
||||
policy:
|
||||
$ref: "#/components/schemas/ProvenancePolicy"
|
||||
rewrites:
|
||||
$ref: "#/components/schemas/ProvenanceRewrites"
|
||||
route:
|
||||
$ref: "#/components/schemas/ProvenanceRoute"
|
||||
response:
|
||||
$ref: "#/components/schemas/ProvenanceResponse"
|
||||
|
||||
QueryDetail:
|
||||
type: object
|
||||
description: |
|
||||
`Provenance` plus the row id. Written out rather than composed with
|
||||
`allOf` so the drift guard reads one property list per schema.
|
||||
One query, fully explained, in the order a query meets the pipeline,
|
||||
plus the row id. Written out rather than composed with `allOf` so the
|
||||
drift guard reads one property list per schema.
|
||||
required: [id, request, group, policy, rewrites, route, response]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ fn matchPattern(pattern: []const u8, segments: []const []const u8) ??i64 {
|
||||
|
||||
/// Fills `buf` with the `Allow` header value for a path that matched under
|
||||
/// other methods. The returned slice borrows `buf`.
|
||||
pub fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
|
||||
fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
|
||||
var writer: std.Io.Writer = .fixed(buf);
|
||||
var first = true;
|
||||
for (table) |*route| {
|
||||
|
||||
+2
-2
@@ -301,7 +301,7 @@ pub const QuerylogRead = struct {
|
||||
/// one, so a password set through the API locks the routes without a restart.
|
||||
/// With it set but no session store wired, every session route is refused: the
|
||||
/// failure mode of a half-wired server must be locked, not open.
|
||||
pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool {
|
||||
fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool {
|
||||
if (!state.live_hash.enabled(io)) return true;
|
||||
const sessions = state.sessions orelse return false;
|
||||
const cookie = http_util.cookieValue(request.cookie, auth.cookie_name) orelse return false;
|
||||
@@ -313,7 +313,7 @@ pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Reque
|
||||
///
|
||||
/// Keyed on `client_addr`, not on the socket peer: behind a trusted proxy every
|
||||
/// peer is the proxy, and one bucket for every remote user is no limiter at all.
|
||||
pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
|
||||
fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
|
||||
const limiter = state.limiter orelse return .ok;
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
return limiter.check(io, now, address.NetAddress.fromIp(request.client_addr));
|
||||
|
||||
+3
-3
@@ -77,7 +77,7 @@ fn find(files: []const File, path: []const u8) ?*const File {
|
||||
/// parameters fall outside the grammar is unusable and refuses. An empty
|
||||
/// header (or one the connection budget dropped) reads as identity-only,
|
||||
/// which degrades to the uncompressed entry.
|
||||
pub fn acceptsGzip(header: []const u8) bool {
|
||||
fn acceptsGzip(header: []const u8) bool {
|
||||
var gzip_entry: ?bool = null;
|
||||
var wildcard_entry: ?bool = null;
|
||||
var tokens = std.mem.splitScalar(u8, header, ',');
|
||||
@@ -122,7 +122,7 @@ fn qualityAccepts(value: []const u8) bool {
|
||||
/// Whether an `if-none-match` header names `etag` (which carries its quotes).
|
||||
/// Weak validators compare by content: a `W/` prefix on the wire still matches,
|
||||
/// because the bytes behind a content hash are the content.
|
||||
pub fn etagMatches(header: []const u8, etag: []const u8) bool {
|
||||
fn etagMatches(header: []const u8, etag: []const u8) bool {
|
||||
var tokens = std.mem.splitScalar(u8, header, ',');
|
||||
while (tokens.next()) |token| {
|
||||
var candidate = std.mem.trim(u8, token, " \t");
|
||||
@@ -179,7 +179,7 @@ fn respondAsset(request: *http_util.Request, selection: Selection) http_util.Han
|
||||
/// any segment could escape the root. Segments were split before percent
|
||||
/// decoding, so a decoded segment may contain `/` — that and `..` are the two
|
||||
/// traversal shapes, and both are refused rather than normalized.
|
||||
pub fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 {
|
||||
fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 {
|
||||
if (segments.len == 0) return index_path[1..];
|
||||
var writer: std.Io.Writer = .fixed(buf);
|
||||
for (segments, 0..) |segment, index| {
|
||||
|
||||
@@ -1174,7 +1174,7 @@ const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path
|
||||
/// do produce paths like this, and the old code answered them in `text/plain`.
|
||||
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
|
||||
|
||||
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
|
||||
fn fileModeConfigWrites(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
@@ -1185,21 +1185,34 @@ fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// Every class of configuration write answers the one envelope.
|
||||
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
|
||||
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
|
||||
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
|
||||
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
|
||||
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
|
||||
};
|
||||
for (writes) |write| {
|
||||
try conn.request(write.method, write.target, null, write.body);
|
||||
// Every configuration write answers the one envelope — enumerated, not
|
||||
// sampled. The cases come from the contract table because each entry
|
||||
// carries a target and a body the handler would accept: a request the
|
||||
// handler would reject anyway could answer 400 and still look like a pass.
|
||||
var enumerated: usize = 0;
|
||||
for (contract) |entry| {
|
||||
if (entry.policy != .config_write) continue;
|
||||
enumerated += 1;
|
||||
|
||||
try conn.request(@tagName(entry.method), entry.target, null, entry.body);
|
||||
response = try conn.receive(&body_buf);
|
||||
errdefer std.debug.print(
|
||||
"{t} {s}: {d} {s}\n",
|
||||
.{ entry.method, entry.target, response.status, response.body },
|
||||
);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expectEqualStrings(managed_body, response.body);
|
||||
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
||||
}
|
||||
|
||||
// The served table is the authority on what a configuration write is, so a
|
||||
// route added there cannot ship without a case here.
|
||||
var served: usize = 0;
|
||||
for (router.routes) |route| {
|
||||
if (route.policy == .config_write) served += 1;
|
||||
}
|
||||
try testing.expectEqual(served, enumerated);
|
||||
|
||||
// Rejected before the handler, not after it: the group was never created.
|
||||
try conn.request("GET", "/api/groups", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
@@ -1377,7 +1390,7 @@ test "W10 milestone 20: file authority rejects configuration writes and spares t
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
|
||||
try bounded(env.io(), default_budget, fileModeConfigWrites, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
|
||||
@@ -3610,8 +3623,10 @@ test "drift guard c: the stats schemas match the structs that serialize them" {
|
||||
test "drift guard c: the query-log schemas match the structs that serialize them" {
|
||||
const gpa = testing.allocator;
|
||||
try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow");
|
||||
// `Provenance` has no schema of its own: it is `QueryDetail` without the
|
||||
// row id, and the live stream documents it in prose rather than a `$ref`
|
||||
// no path could honestly point at.
|
||||
try expectSchemaMatches(gpa, provenance_view.QueryDetail, "QueryDetail");
|
||||
try expectSchemaMatches(gpa, provenance_view.Provenance, "Provenance");
|
||||
try expectSchemaMatches(gpa, coverage_mod.Coverage, "Coverage");
|
||||
}
|
||||
|
||||
@@ -3641,6 +3656,7 @@ test "drift guard c bites: a renamed, retyped or newly optional field fails it"
|
||||
// The same drift behind a `$ref`, where the property carries no `type:` of
|
||||
// its own: a nested object the server may now omit.
|
||||
const NullableObject = struct {
|
||||
id: i64,
|
||||
request: provenance_view.Request,
|
||||
group: ?provenance_view.Group,
|
||||
policy: provenance_view.Policy,
|
||||
@@ -3648,7 +3664,7 @@ test "drift guard c bites: a renamed, retyped or newly optional field fails it"
|
||||
route: provenance_view.Route,
|
||||
response: provenance_view.Response,
|
||||
};
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "Provenance"));
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "QueryDetail"));
|
||||
|
||||
// And behind a `$ref` to an enum, whose values would still line up.
|
||||
const NullableEnum = struct {
|
||||
@@ -3704,6 +3720,13 @@ const ContractSample = struct {
|
||||
/// route runs after the create that gave it a row (an empty array witnesses no
|
||||
/// field at all), and `POST /api/blocklists/update` runs while the only source
|
||||
/// row is disabled, so the pass syncs its status without fetching anything.
|
||||
///
|
||||
/// Every successful `.json` route is sampled except three, which carry no JSON
|
||||
/// contract this file could pin: `/metrics` answers Prometheus text,
|
||||
/// `/api/openapi.yaml` is served verbatim from the repo and guarded by the
|
||||
/// drift tests below, and `/api/queries/live` is an open SSE stream rather than
|
||||
/// one byte-comparable body — its frame payload is `QueryDetail` without the
|
||||
/// id, already sampled through `get_query_detail`.
|
||||
const contract_sample_walk = [_]ContractSample{
|
||||
.{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 },
|
||||
.{ .name = "get_version", .ts_type = "Version", .method = "GET", .target = "/api/version", .status = 200 },
|
||||
@@ -3722,6 +3745,7 @@ const contract_sample_walk = [_]ContractSample{
|
||||
// Blocklists. The row is created disabled so the refresh below has a status
|
||||
// to report and still downloads nothing.
|
||||
.{ .name = "create_blocklist", .ts_type = "BlocklistEcho", .method = "POST", .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\",\"enabled\":false}", .status = 201 },
|
||||
.{ .name = "get_blocklist", .ts_type = "Blocklist", .method = "GET", .target = "/api/blocklists/1", .status = 200 },
|
||||
.{ .name = "list_blocklists", .ts_type = "{ blocklists: Blocklist[] }", .method = "GET", .target = "/api/blocklists", .status = 200 },
|
||||
.{ .name = "update_blocklist", .ts_type = "BlocklistEcho", .method = "PUT", .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200 },
|
||||
.{ .name = "update_blocklists_now", .ts_type = "{ sources: SourceStatus[] }", .method = "POST", .target = "/api/blocklists/update", .body = "{}", .status = 202 },
|
||||
@@ -3729,28 +3753,33 @@ const contract_sample_walk = [_]ContractSample{
|
||||
// Groups. The migrated schema seeds `default` as id 1; the POST creates 2.
|
||||
.{ .name = "list_groups", .ts_type = "{ groups: Group[] }", .method = "GET", .target = "/api/groups", .status = 200 },
|
||||
.{ .name = "create_group", .ts_type = "Group", .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201 },
|
||||
.{ .name = "get_group", .ts_type = "Group", .method = "GET", .target = "/api/groups/2", .status = 200 },
|
||||
.{ .name = "update_group", .ts_type = "Group", .method = "PUT", .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200 },
|
||||
.{ .name = "put_group_sources", .ts_type = "{ source_ids: number[] }", .method = "PUT", .target = "/api/groups/1/sources", .body = "{\"source_ids\":[1]}", .status = 200 },
|
||||
.{ .name = "get_group_sources", .ts_type = "{ source_ids: number[] }", .method = "GET", .target = "/api/groups/1/sources", .status = 200 },
|
||||
|
||||
// Rules, then the lookup that the rule makes answer `blocked`.
|
||||
.{ .name = "create_rule", .ts_type = "RuleEcho", .method = "POST", .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201 },
|
||||
.{ .name = "get_rule", .ts_type = "Rule", .method = "GET", .target = "/api/rules/1", .status = 200 },
|
||||
.{ .name = "list_rules", .ts_type = "{ rules: Rule[] }", .method = "GET", .target = "/api/rules", .status = 200 },
|
||||
.{ .name = "update_rule", .ts_type = "RuleEcho", .method = "PUT", .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"*.ads.example\",\"kind\":\"wildcard\",\"action\":\"block\"}", .status = 200 },
|
||||
.{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 },
|
||||
|
||||
// Local records.
|
||||
.{ .name = "create_local_record", .ts_type = "LocalRecord", .method = "POST", .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201 },
|
||||
.{ .name = "get_local_record", .ts_type = "LocalRecord", .method = "GET", .target = "/api/local-records/1", .status = 200 },
|
||||
.{ .name = "list_local_records", .ts_type = "{ local_records: LocalRecord[] }", .method = "GET", .target = "/api/local-records", .status = 200 },
|
||||
.{ .name = "update_local_record", .ts_type = "LocalRecord", .method = "PUT", .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200 },
|
||||
|
||||
// Forward zones.
|
||||
.{ .name = "create_forward_zone", .ts_type = "ForwardZone", .method = "POST", .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201 },
|
||||
.{ .name = "get_forward_zone", .ts_type = "ForwardZone", .method = "GET", .target = "/api/forward-zones/1", .status = 200 },
|
||||
.{ .name = "list_forward_zones", .ts_type = "{ forward_zones: ForwardZone[] }", .method = "GET", .target = "/api/forward-zones", .status = 200 },
|
||||
.{ .name = "update_forward_zone", .ts_type = "ForwardZone", .method = "PUT", .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200 },
|
||||
|
||||
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
|
||||
.{ .name = "list_clients", .ts_type = "{ clients: Client[] }", .method = "GET", .target = "/api/clients", .status = 200 },
|
||||
.{ .name = "get_client", .ts_type = "Client", .method = "GET", .target = "/api/clients/1", .status = 200 },
|
||||
.{ .name = "update_client", .ts_type = "Client", .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200 },
|
||||
.{ .name = "put_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "PUT", .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200 },
|
||||
.{ .name = "list_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "GET", .target = "/api/client-prefixes", .status = 200 },
|
||||
@@ -3759,6 +3788,7 @@ const contract_sample_walk = [_]ContractSample{
|
||||
// conflict sample below can collide with it.
|
||||
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
|
||||
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
|
||||
.{ .name = "get_upstream", .ts_type = "Upstream", .method = "GET", .target = "/api/upstreams/1", .status = 200 },
|
||||
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
|
||||
|
||||
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
|
||||
|
||||
Reference in New Issue
Block a user