Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc23c97218
|
||
|
|
5da4652e89
|
@@ -37,6 +37,8 @@ Query provenance: every logged query becomes exactly explainable — what the po
|
|||||||
- **Upgrading resets your query history.** The `query_log` table gains the provenance columns below, and `querylog.db` is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as `querylog.db.schema-changed-<unix seconds>` and a fresh one is created. Nothing else is touched: `config.db` keeps your configuration and your diagnostics history. The recreate files a resolved `query_log.recreated` diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new `querylog_meta` table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it.
|
- **Upgrading resets your query history.** The `query_log` table gains the provenance columns below, and `querylog.db` is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as `querylog.db.schema-changed-<unix seconds>` and a fresh one is created. Nothing else is touched: `config.db` keeps your configuration and your diagnostics history. The recreate files a resolved `query_log.recreated` diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new `querylog_meta` table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it.
|
||||||
- **`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.
|
- **`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`.
|
- **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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"typecheck": "tsc -b",
|
||||||
"lint": "oxlint src vite.config.ts",
|
"lint": "oxlint src vite.config.ts",
|
||||||
"format": "prettier --write .",
|
"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 { createQueryClient } from "@/lib/queryClient";
|
||||||
import { createAppRouter } from "@/routes";
|
import { createAppRouter } from "@/routes";
|
||||||
import type { QueryDetail } from "@/lib/types";
|
import type { QueryDetail } from "@/lib/types";
|
||||||
import { provenance } from "@/features/queries/provenanceFixture";
|
import { provenance } from "@/features/provenance/provenanceFixture";
|
||||||
import { health } from "@/lib/healthFixture";
|
import { health } from "@/lib/healthFixture";
|
||||||
|
|
||||||
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createQueryClient } from "@/lib/queryClient";
|
|||||||
import { createAppRouter } from "@/routes";
|
import { createAppRouter } from "@/routes";
|
||||||
import { health } from "@/lib/healthFixture";
|
import { health } from "@/lib/healthFixture";
|
||||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
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 {
|
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import InlineError from "@/lib/InlineError";
|
|||||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||||
import type { QueryRow } from "@/lib/types";
|
import type { QueryRow } from "@/lib/types";
|
||||||
import { useClientNames } from "@/features/clients/clientNames";
|
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 { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AuthProvider } from "@/auth/store";
|
|||||||
import { createQueryClient } from "@/lib/queryClient";
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
import { createAppRouter } from "@/routes";
|
import { createAppRouter } from "@/routes";
|
||||||
import type { Client } from "@/lib/types";
|
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 { health } from "@/lib/healthFixture";
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useClientNames } from "@/features/clients/clientNames";
|
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 { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import {
|
|||||||
qclassName,
|
qclassName,
|
||||||
rcodeName,
|
rcodeName,
|
||||||
routeKindLabel,
|
routeKindLabel,
|
||||||
} from "@/features/queries/provenanceCopy";
|
} from "@/features/provenance/provenanceCopy";
|
||||||
import { qtypeName } from "@/features/queries/qtype";
|
import { qtypeName } from "@/features/provenance/qtype";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import type { QueryRow } from "@/lib/types";
|
import type { QueryRow } from "@/lib/types";
|
||||||
import type { ClientNames } from "@/features/clients/clientNames";
|
import type { ClientNames } from "@/features/clients/clientNames";
|
||||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
import { summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
|
||||||
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
||||||
|
|
||||||
const noNames: ClientNames = new Map();
|
const noNames: ClientNames = new Map();
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import * as stylex from "@stylexjs/stylex";
|
|||||||
import { formatMicros, formatTime } from "@/lib/format";
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
import type { RouteKind } from "@/lib/types";
|
import type { RouteKind } from "@/lib/types";
|
||||||
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||||
import { rcodeShortName } from "@/features/queries/provenanceCopy";
|
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
|
||||||
import { qtypeName } from "@/features/queries/qtype";
|
import { qtypeName } from "@/features/provenance/qtype";
|
||||||
import type { QuerySummary } from "@/features/queries/querySummary";
|
import type { QuerySummary } from "@/features/provenance/querySummary";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
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 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";
|
import { RING_CAPACITY, mergeGap, pushRow, summaryOf, type LiveRow } from "./ringBuffer";
|
||||||
|
|
||||||
function streamed(key: number, ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): LiveRow {
|
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 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
|
* 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 { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import type { QueriesPage, QueryRow } from "@/lib/types";
|
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 { summaryOf, type LiveRow } from "./ringBuffer";
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
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 { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import CoverageNotice from "@/lib/CoverageNotice";
|
import CoverageNotice from "@/lib/CoverageNotice";
|
||||||
import InlineError from "@/lib/InlineError";
|
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 type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
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 listRules = async (): Promise<Rule[]> => (await request<{ rules: Rule[] }>("/api/rules")).rules;
|
||||||
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
|
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
|
||||||
request("/api/rules", { method: "POST", body: input });
|
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" });
|
export const deleteRule = (id: number): Promise<void> => request(`/api/rules/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
// Local records
|
// Local records
|
||||||
|
|||||||
@@ -148,6 +148,21 @@ export const sample_create_blocklist: BlocklistEcho = {
|
|||||||
url: "https://lists.example/ads.txt",
|
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[] } = {
|
export const sample_list_blocklists: { blocklists: Blocklist[] } = {
|
||||||
blocklists: [
|
blocklists: [
|
||||||
{
|
{
|
||||||
@@ -210,6 +225,12 @@ export const sample_create_group: Group = {
|
|||||||
safe_search: false,
|
safe_search: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sample_get_group: Group = {
|
||||||
|
id: 0,
|
||||||
|
name: "kids",
|
||||||
|
safe_search: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const sample_update_group: Group = {
|
export const sample_update_group: Group = {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: "teens",
|
name: "teens",
|
||||||
@@ -232,6 +253,16 @@ export const sample_create_rule: RuleEcho = {
|
|||||||
pattern: "ads.example",
|
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[] } = {
|
export const sample_list_rules: { rules: Rule[] } = {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
@@ -274,6 +305,14 @@ export const sample_create_local_record: LocalRecord = {
|
|||||||
value: "192.168.1.10",
|
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[] } = {
|
export const sample_list_local_records: { local_records: LocalRecord[] } = {
|
||||||
local_records: [
|
local_records: [
|
||||||
{
|
{
|
||||||
@@ -300,6 +339,12 @@ export const sample_create_forward_zone: ForwardZone = {
|
|||||||
zone: "lan",
|
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[] } = {
|
export const sample_list_forward_zones: { forward_zones: ForwardZone[] } = {
|
||||||
forward_zones: [
|
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 = {
|
export const sample_update_client: Client = {
|
||||||
first_seen: 0,
|
first_seen: 0,
|
||||||
group: "default",
|
group: "default",
|
||||||
@@ -389,6 +446,14 @@ export const sample_create_upstream: UpstreamEcho = {
|
|||||||
url: "https://dns2.example/dns-query",
|
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 = {
|
export const sample_update_upstream: UpstreamEcho = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export interface LogoutResponse {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The three closed enums `src/storage/provenance.zig` stores, as values rather
|
* 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
|
* be proven exhaustive at runtime as well as by `tsc`, exactly as
|
||||||
* `DIAGNOSTIC_CODES` below.
|
* `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:
|
* 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.
|
* type was never recorded keeps its own `null` group rather than disappearing.
|
||||||
*/
|
*/
|
||||||
export interface StatsTypeRow {
|
export interface StatsTypeRow {
|
||||||
@@ -396,10 +396,6 @@ export interface GroupInput {
|
|||||||
safe_search?: boolean;
|
safe_search?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSources {
|
|
||||||
source_ids: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Blocklist {
|
export interface Blocklist {
|
||||||
id: number;
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
|
|||||||
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
|
-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.
|
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 |
|
| 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/login` | open | counted | runtime action | Log in |
|
||||||
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
|
| 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/{id}` | session | counted | read | One query, fully explained |
|
||||||
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
||||||
| GET | `/api/stats` | session | counted | read | Totals for a period |
|
| 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:
|
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.
|
- **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.
|
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
|
## 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
|
## 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).
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# querylog.db: wal_autocheckpoint = 8192
|
|
||||||
|
|
||||||
One constant. The v0.0.7 batching cut process writes from ~0.5 to 0.281 GiB/day (measured over a 10 h process lifetime on the Pi); ~130 MiB/day of the remainder is autocheckpoint writeback — SQLite's 1000-page default trips every ~40 min and rewrites the same hot index/interior pages into the main db each time. At 8192 pages (32 MiB at the 4096-byte page size) the cadence drops to ~5 h, cutting those in-place rewrites ~8x, expected total ≈190 MiB/day. The previous SD card died of write wear; the current card's endurance is unknown, which argues for cutting known writes, not against it. Codex approved the decision and this shape (thread 01a0205d).
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
`PRAGMA wal_autocheckpoint = 8192` on every read-write querylog.db connection. Hardcoded constant, no config knob, no checkpoint task. `synchronous=NORMAL` and the daily retention `wal_checkpoint(TRUNCATE)` (queries_repo.zig:169, called from the retention pass) stay as they are. config.db — including the diagnostics store's connection, which `app.zig:416` opens via `openConfigDb` despite the variable name `events_db` — keeps the SQLite default. There are exactly two database files; nothing named events.db exists.
|
|
||||||
|
|
||||||
## Durability contract (goes in the constant's comment, stated precisely)
|
|
||||||
|
|
||||||
- Commit never fsyncs at `synchronous=NORMAL`; the checkpoint's fsync is the only guaranteed durability boundary. This change moves that boundary from ~40 min to ~5 h of querylog data (query rows + upstream-history minutes) under power loss or kernel panic. Typical loss stays far smaller (kernel writeback), but that is not a guarantee.
|
|
||||||
- Process crash or clean stop loses nothing committed, at any threshold. Consistency is never at risk: recovery replays the longest valid WAL prefix atomically.
|
|
||||||
- 32 MiB is an expectation, not a cap: a pinned reader snapshot stops a passive checkpoint partway and the WAL overshoots until the reader finishes; the daily TRUNCATE is the backstop that shrinks the file.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
1. **src/storage/db.zig** — `Pragmas` (:753) gains `wal_autocheckpoint_pages: ?i32 = null`. `applyPragmas` (:762), when non-null: `PRAGMA wal_autocheckpoint = N;` then read back via the pragma's own return and fail loudly on mismatch — mirror the `foreign_keys` set-and-verify at :781-783. Default null leaves every existing `.{}` caller (config.db sites, tests) untouched with zero diffs. db.zig stays generic; it must not know the word querylog.
|
|
||||||
2. **src/storage/querylog_schema.zig** — owns the constant (the module already owns querylog policy: fingerprint, DDL, recreate classification): `pub const wal_autocheckpoint_pages: i32 = 8192;` carrying the durability contract above as its comment. Passed at both production `applyPragmas` sites: the probe path (:129) and `createFresh` (:253). The third `applyPragmas` in that file (:273) is inside an in-memory test and stays `.{}` deliberately.
|
|
||||||
3. **src/cli.zig** — `reopenQuerylogDb` (:354) passes the constant. These three sites are the only read-write querylog connections by construction — every open flows through `querylog_schema.open` or `DataDir.reopenQuerylogDb`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Unit, db.zig, in-memory (the pragma reads back per-connection regardless of journal mode): default `Pragmas` leaves `PRAGMA wal_autocheckpoint` at 1000; a set value reads back.
|
|
||||||
- File-backed storage integration test through the real helpers: `openQuerylogDb` and `reopenQuerylogDb` connections both read back 8192; an `openConfigDb` connection reads 1000.
|
|
||||||
- The read-back inside `applyPragmas` makes misapplication loud at startup, complementing both.
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
- CHANGELOG Unreleased, Changed: the checkpoint cadence change, the measured why, and the widened power-loss window stated per the durability contract (not as an unconditional bound).
|
|
||||||
- One short paragraph appended to specs/querylog-batching.md linking here.
|
|
||||||
|
|
||||||
## Rejected (do not relitigate without new facts)
|
|
||||||
|
|
||||||
- Config knob: nobody tunes this twice; scope is small on purpose.
|
|
||||||
- Periodic checkpoint task: reproduces autocheckpoint with more moving parts (cadence state, busy handling, shutdown, diagnostics).
|
|
||||||
- `wal_autocheckpoint=0` + daily TRUNCATE only: unbounded intraday WAL growth under reader pinning; strictly worse.
|
|
||||||
- `journal_size_limit`: redundant with the daily TRUNCATE, and rejecting it needs no claim about passive checkpoints never truncating (after a completed checkpoint resets the WAL, the limit does truncate on the next write — the knob is merely surplus here).
|
|
||||||
- Touching config.db policy or differentiating reader vs writer querylog connections: readers cannot trip checkpoints, the pragma is inert on them; uniformity is simpler.
|
|
||||||
|
|
||||||
## Gates
|
|
||||||
|
|
||||||
1. `zig build test` and `-Dintegration` 0 failed; fmt clean.
|
|
||||||
2. Field verification on the released build (the Pi deploys releases, not branches, so this necessarily follows the cut — owner-ordered 2026-08-21): confirm the WAL resets normally, writeback falls materially, and no batches drop. What to measure and over what window is the deployment side's call; a bad result reverts the constant in a follow-up patch release.
|
|
||||||
@@ -19,10 +19,6 @@ Model key + round-trip drift guards, validation + validation reference, settings
|
|||||||
- Crash-loss window: up to `interval` seconds of query history on process failure; power loss can additionally lose recent committed transactions (WAL + synchronous=NORMAL). Query history is the least valuable data on the box.
|
- Crash-loss window: up to `interval` seconds of query history on process failure; power loss can additionally lose recent committed transactions (WAL + synchronous=NORMAL). Query history is the least valuable data on the box.
|
||||||
- Staleness: every query-log-backed read (query-log page, totals, timeseries) lags up to `interval` seconds. The live view is unaffected — it is fed from the hub before the queue.
|
- Staleness: every query-log-backed read (query-log page, totals, timeseries) lags up to `interval` seconds. The live view is unaffected — it is fed from the hub before the queue.
|
||||||
|
|
||||||
## Follow-up
|
|
||||||
|
|
||||||
The "unchanged on purpose" line above no longer holds for `wal_autocheckpoint`. Batching landed and the Pi measured 0.281 GiB/day, of which ~130 MiB is autocheckpoint writeback — second-order next to one transaction per query, first-order next to one per minute. specs/querylog-autocheckpoint.md raises the threshold to 8192 pages on every read-write `querylog.db` connection and states the durability contract that comes with it. Nothing else in this spec changes.
|
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [ ] Deterministic tests: interval batching (entries within the window land in one transaction), flush_batch early flush, 0-sentinel immediate flush, shutdown drains a held batch and the queue (the race sequence: producers stopped → queue closed → writer awaited), disk-gated final drain counts drops.
|
- [ ] Deterministic tests: interval batching (entries within the window land in one transaction), flush_batch early flush, 0-sentinel immediate flush, shutdown drains a held batch and the queue (the race sequence: producers stopped → queue closed → writer awaited), disk-gated final drain counts drops.
|
||||||
|
|||||||
+1
-3
@@ -351,9 +351,7 @@ pub const DataDir = struct {
|
|||||||
_ = io;
|
_ = io;
|
||||||
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
||||||
errdefer database.close();
|
errdefer database.close();
|
||||||
try db.applyPragmas(&database, .{
|
try db.applyPragmas(&database, .{});
|
||||||
.wal_autocheckpoint_pages = querylog_schema.wal_autocheckpoint_pages,
|
|
||||||
});
|
|
||||||
return database;
|
return database;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -755,9 +755,6 @@ pub const Pragmas = struct {
|
|||||||
journal_wal: bool = true,
|
journal_wal: bool = true,
|
||||||
synchronous_normal: bool = true,
|
synchronous_normal: bool = true,
|
||||||
foreign_keys: bool = true,
|
foreign_keys: bool = true,
|
||||||
/// Null leaves SQLite's 1000-page default. A caller that sets it owns the
|
|
||||||
/// durability consequences, which depend on what the database holds.
|
|
||||||
wal_autocheckpoint_pages: ?i32 = null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
|
/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
|
||||||
@@ -789,16 +786,6 @@ pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
|
|||||||
return error.SqliteError;
|
return error.SqliteError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (p.wal_autocheckpoint_pages) |pages| {
|
|
||||||
var buf: [64]u8 = undefined;
|
|
||||||
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA wal_autocheckpoint = {d};", .{pages}) catch unreachable;
|
|
||||||
try self.exec(sql);
|
|
||||||
const applied = try self.queryInt("PRAGMA wal_autocheckpoint");
|
|
||||||
if (applied != pages) {
|
|
||||||
log.warn("PRAGMA wal_autocheckpoint = {d} reported {d}", .{ pages, applied });
|
|
||||||
return error.SqliteError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A write transaction.
|
/// A write transaction.
|
||||||
@@ -1052,18 +1039,6 @@ test "applyPragmas succeeds and foreign_keys reads back as 1" {
|
|||||||
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
|
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "applyPragmas leaves wal_autocheckpoint at the default unless a page count is given" {
|
|
||||||
var db = try openMemory();
|
|
||||||
defer db.close();
|
|
||||||
try applyPragmas(&db, .{});
|
|
||||||
try testing.expectEqual(@as(i64, 1000), try db.queryInt("PRAGMA wal_autocheckpoint"));
|
|
||||||
|
|
||||||
var configured = try openMemory();
|
|
||||||
defer configured.close();
|
|
||||||
try applyPragmas(&configured, .{ .wal_autocheckpoint_pages = 8192 });
|
|
||||||
try testing.expectEqual(@as(i64, 8192), try configured.queryInt("PRAGMA wal_autocheckpoint"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "open on a directory path returns error.CantOpen and leaks no handle" {
|
test "open on a directory path returns error.CantOpen and leaks no handle" {
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < 1000) : (i += 1) {
|
while (i < 1000) : (i += 1) {
|
||||||
|
|||||||
@@ -92,31 +92,6 @@ pub const fingerprint: i32 = blk: {
|
|||||||
|
|
||||||
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
|
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
|
||||||
|
|
||||||
/// The WAL checkpoint threshold for every read-write `querylog.db` connection,
|
|
||||||
/// in pages (32 MiB at the 4096-byte page size). SQLite's 1000-page default
|
|
||||||
/// trips every ~40 min under this workload and rewrites the same hot index and
|
|
||||||
/// interior pages into the main database each time; 8192 stretches that to ~5 h
|
|
||||||
/// and cuts those in-place rewrites ~8x, which is SD-card write wear this
|
|
||||||
/// household appliance does not need to spend.
|
|
||||||
///
|
|
||||||
/// The durability consequence, stated precisely:
|
|
||||||
///
|
|
||||||
/// - Commit never fsyncs at `synchronous = NORMAL`; the checkpoint's fsync is
|
|
||||||
/// the only guaranteed durability boundary. This moves that boundary from
|
|
||||||
/// ~40 min to ~5 h of querylog data under power loss or kernel panic.
|
|
||||||
/// Typical loss stays far smaller because of kernel writeback, but that is
|
|
||||||
/// not a guarantee.
|
|
||||||
/// - Process crash or clean stop loses nothing committed, at any threshold.
|
|
||||||
/// Consistency is never at risk: recovery replays the longest valid WAL
|
|
||||||
/// prefix atomically.
|
|
||||||
/// - 32 MiB is an expectation, not a cap: a pinned reader snapshot stops a
|
|
||||||
/// passive checkpoint partway and the WAL overshoots until that reader
|
|
||||||
/// finishes. The daily retention `wal_checkpoint(TRUNCATE)` is the backstop
|
|
||||||
/// that shrinks the file.
|
|
||||||
///
|
|
||||||
/// `config.db` keeps the SQLite default: it holds configuration, not a log.
|
|
||||||
pub const wal_autocheckpoint_pages: i32 = 8192;
|
|
||||||
|
|
||||||
/// Long enough for any path this program will be handed, plus the aside suffix.
|
/// Long enough for any path this program will be handed, plus the aside suffix.
|
||||||
/// A longer path is `error.NameTooLong`, which is what the filesystem calls
|
/// A longer path is `error.NameTooLong`, which is what the filesystem calls
|
||||||
/// would have returned anyway.
|
/// would have returned anyway.
|
||||||
@@ -166,7 +141,7 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
|
|||||||
break :probe recreatable(e) orelse return e;
|
break :probe recreatable(e) orelse return e;
|
||||||
const opened = &handle.?;
|
const opened = &handle.?;
|
||||||
|
|
||||||
db.applyPragmas(opened, .{ .wal_autocheckpoint_pages = wal_autocheckpoint_pages }) catch |e|
|
db.applyPragmas(opened, .{}) catch |e|
|
||||||
break :probe recreatable(e) orelse return e;
|
break :probe recreatable(e) orelse return e;
|
||||||
|
|
||||||
const healthy = quickCheck(opened) catch |e|
|
const healthy = quickCheck(opened) catch |e|
|
||||||
@@ -290,7 +265,7 @@ fn deleteSidecars(io: std.Io, dir: std.Io.Dir, path: []const u8) Error!void {
|
|||||||
fn createFresh(path: [:0]const u8) db.Error!db.Db {
|
fn createFresh(path: [:0]const u8) db.Error!db.Db {
|
||||||
var database = try db.Db.open(path, .{ .mode = .read_write_create });
|
var database = try db.Db.open(path, .{ .mode = .read_write_create });
|
||||||
errdefer database.close();
|
errdefer database.close();
|
||||||
try db.applyPragmas(&database, .{ .wal_autocheckpoint_pages = wal_autocheckpoint_pages });
|
try db.applyPragmas(&database, .{});
|
||||||
|
|
||||||
var tx = try db.Tx.begin(&database);
|
var tx = try db.Tx.begin(&database);
|
||||||
errdefer tx.rollback();
|
errdefer tx.rollback();
|
||||||
|
|||||||
@@ -600,36 +600,6 @@ test "S7 case 23: a locked querylog propagates Busy and is never destroyed" {
|
|||||||
try testing.expectEqual(@as(usize, 0), asides_after.items.items.len);
|
try testing.expectEqual(@as(usize, 0), asides_after.items.items.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// the querylog checkpoint threshold (specs/querylog-autocheckpoint.md)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test "every querylog connection carries the raised wal_autocheckpoint; config.db keeps the default" {
|
|
||||||
if (!build_options.integration) return error.SkipZigTest;
|
|
||||||
|
|
||||||
var f: Fixture = .init();
|
|
||||||
defer f.deinit();
|
|
||||||
|
|
||||||
var data = try openMigrated(&f, "data");
|
|
||||||
defer data.deinit();
|
|
||||||
|
|
||||||
var opened = try data.dir.openQuerylogDb(io);
|
|
||||||
defer opened.database.close();
|
|
||||||
try testing.expectEqual(
|
|
||||||
@as(i64, querylog_schema.wal_autocheckpoint_pages),
|
|
||||||
try opened.database.queryInt("PRAGMA wal_autocheckpoint"),
|
|
||||||
);
|
|
||||||
|
|
||||||
var reopened = try data.dir.reopenQuerylogDb(io);
|
|
||||||
defer reopened.close();
|
|
||||||
try testing.expectEqual(
|
|
||||||
@as(i64, querylog_schema.wal_autocheckpoint_pages),
|
|
||||||
try reopened.queryInt("PRAGMA wal_autocheckpoint"),
|
|
||||||
);
|
|
||||||
|
|
||||||
try testing.expectEqual(@as(i64, 1000), try data.database.queryInt("PRAGMA wal_autocheckpoint"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// case 8-10: config.db, permissions and the schema stamp
|
// case 8-10: config.db, permissions and the schema stamp
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
/// 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
|
/// An IPv4-mapped loopback address has already normalized to `.ip4` by the time
|
||||||
/// a `NetAddress` exists (`address.zig:51`).
|
/// a `NetAddress` exists (`address.zig:51`).
|
||||||
pub fn isLoopback(addr: address.NetAddress) bool {
|
fn isLoopback(addr: address.NetAddress) bool {
|
||||||
return switch (addr) {
|
return switch (addr) {
|
||||||
.ip4 => |b| b[0] == 127,
|
.ip4 => |b| b[0] == 127,
|
||||||
.ip6 => |b| std.mem.eql(u8, &b, &[_]u8{0} ** 15 ++ [_]u8{1}),
|
.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
|
/// 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.
|
/// 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 sessions = state.sessions orelse return false;
|
||||||
const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false;
|
const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false;
|
||||||
return sessions.logout(io, value);
|
return sessions.logout(io, value);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pub const StatusView = struct {
|
|||||||
// decisions
|
// decisions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn applyCreate(
|
fn applyCreate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -103,7 +103,7 @@ pub fn applyCreate(
|
|||||||
return .{ .id = id };
|
return .{ .id = id };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyUpdate(
|
fn applyUpdate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -121,7 +121,7 @@ pub fn applyUpdate(
|
|||||||
return mutations.reload(state, io);
|
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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
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
|
/// `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
|
/// too, because it is how the composition root learns that a change landed and
|
||||||
/// the only reload a test can observe.
|
/// 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,
|
statuses: usize,
|
||||||
fail: Failure,
|
fail: Failure,
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const PrefixesBody = struct {
|
|||||||
// decisions
|
// decisions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn applyUpdate(
|
fn applyUpdate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -72,7 +72,7 @@ pub fn applyUpdate(
|
|||||||
return mutations.reload(state, io);
|
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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
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);
|
return mutations.reload(state, io);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyReplacePrefixes(
|
fn applyReplacePrefixes(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const Created = union(enum) { id: i64, fail: Failure };
|
|||||||
// decisions
|
// decisions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn applyCreate(
|
fn applyCreate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -68,7 +68,7 @@ pub fn applyCreate(
|
|||||||
return .{ .id = id };
|
return .{ .id = id };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyUpdate(
|
fn applyUpdate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -105,7 +105,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
|
|||||||
return null;
|
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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
state.config_lock.lockUncancelable(io);
|
||||||
@@ -133,7 +133,7 @@ fn deleteLocked(database: *db.Db, arena: Allocator, id: i64) ?Failure {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applySetSources(
|
fn applySetSources(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
id: i64,
|
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.writer_failed) return query_history_failed;
|
||||||
if (input.gate_episode == .losing) return query_history_losing;
|
if (input.gate_episode == .losing) return query_history_losing;
|
||||||
return query_history_recording;
|
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
|
/// 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
|
/// writes are failing. Both mean the same thing to an operator: the record of
|
||||||
/// what went wrong is not being kept.
|
/// 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;
|
return !input.diagnostics_present or input.diagnostics_write_failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `warn` reads as a log level rather than as a quantity of disk. The monitor
|
/// `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.
|
/// 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) {
|
return switch (state) {
|
||||||
.ok => disk_ok,
|
.ok => disk_ok,
|
||||||
.warn => disk_low,
|
.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
|
/// One `event: query` frame. JSON never contains a raw newline, so the whole
|
||||||
/// payload is a single `data:` line.
|
/// 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: ");
|
try w.writeAll("event: query\ndata: ");
|
||||||
var stringify: std.json.Stringify = .{ .writer = w };
|
var stringify: std.json.Stringify = .{ .writer = w };
|
||||||
try stringify.write(view(entry));
|
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);
|
return mutations.reload(state, io);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyCreateRecord(
|
fn applyCreateRecord(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -106,7 +106,7 @@ pub fn applyCreateRecord(
|
|||||||
return .{ .id = id };
|
return .{ .id = id };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyUpdateRecord(
|
fn applyUpdateRecord(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -124,7 +124,7 @@ pub fn applyUpdateRecord(
|
|||||||
return publish(state, io, arena, database);
|
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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
state.config_lock.lockUncancelable(io);
|
||||||
@@ -139,7 +139,7 @@ pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator,
|
|||||||
// forward zones: decisions
|
// forward zones: decisions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn applyCreateZone(
|
fn applyCreateZone(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -157,7 +157,7 @@ pub fn applyCreateZone(
|
|||||||
return .{ .id = id };
|
return .{ .id = id };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyUpdateZone(
|
fn applyUpdateZone(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -175,7 +175,7 @@ pub fn applyUpdateZone(
|
|||||||
return publish(state, io, arena, database);
|
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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
state.config_lock.lockUncancelable(io);
|
||||||
|
|||||||
@@ -390,12 +390,6 @@ pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfM
|
|||||||
return firstProblem(arena, cfg);
|
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 {
|
pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 {
|
||||||
var cfg = skeleton(&default_groups);
|
var cfg = skeleton(&default_groups);
|
||||||
cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }};
|
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 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 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 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, "kids"));
|
||||||
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default"));
|
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 };
|
return .{ .paused = true, .until = until };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply(
|
fn apply(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
body: Body,
|
body: Body,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure
|
|||||||
// decisions
|
// decisions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn applyCreate(
|
fn applyCreate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -74,7 +74,7 @@ pub fn applyCreate(
|
|||||||
return .{ .id = id };
|
return .{ .id = id };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyUpdate(
|
fn applyUpdate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -94,7 +94,7 @@ pub fn applyUpdate(
|
|||||||
return mutations.reload(state, io);
|
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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
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
|
/// Reads, merges, validates, writes, and — when the password changed — ends
|
||||||
/// every session. Returns the configuration as it now stands.
|
/// every session. Returns the configuration as it now stands.
|
||||||
pub fn applyPut(
|
fn applyPut(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
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,
|
/// 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.
|
/// 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;
|
var buf: [8]u8 = undefined;
|
||||||
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
|
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
|
||||||
const text = found orelse return default_period;
|
const text = found orelse return default_period;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const Created = union(enum) { id: i64, fail: Failure };
|
|||||||
// decisions
|
// decisions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn applyCreate(
|
fn applyCreate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -59,7 +59,7 @@ pub fn applyCreate(
|
|||||||
return .{ .id = id };
|
return .{ .id = id };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applyUpdate(
|
fn applyUpdate(
|
||||||
state: *server.WebState,
|
state: *server.WebState,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
@@ -94,7 +94,7 @@ pub fn applyUpdate(
|
|||||||
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
|
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
|
||||||
/// answers nothing, and `validate.validate` refuses that configuration at
|
/// answers nothing, and `validate.validate` refuses that configuration at
|
||||||
/// startup — so allowing it here would only produce a box that will not boot.
|
/// 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;
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
||||||
|
|
||||||
state.config_lock.lockUncancelable(io);
|
state.config_lock.lockUncancelable(io);
|
||||||
|
|||||||
+9
-25
@@ -266,9 +266,12 @@ paths:
|
|||||||
summary: Live query stream (server-sent events)
|
summary: Live query stream (server-sent events)
|
||||||
description: |
|
description: |
|
||||||
`text/event-stream`. The stream opens with `retry: 3000`, then sends
|
`text/event-stream`. The stream opens with `retry: 3000`, then sends
|
||||||
one `event: query` frame per resolved query whose `data:` line is a
|
one `event: query` frame per resolved query. The `data:` line is one
|
||||||
`Provenance` object — the body of `/api/queries/{id}` without its `id`,
|
query fully explained, in the order a query meets the pipeline: the six
|
||||||
which does not exist yet because the entry precedes its own insert.
|
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 `: ping` comment goes out every 15 seconds.
|
||||||
A client that falls more than 64 events behind is disconnected and
|
A client that falls more than 64 events behind is disconnected and
|
||||||
should re-sync via `/api/queries` after reconnecting. Connections
|
should re-sync via `/api/queries` after reconnecting. Connections
|
||||||
@@ -2166,31 +2169,12 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
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:
|
QueryDetail:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
`Provenance` plus the row id. Written out rather than composed with
|
One query, fully explained, in the order a query meets the pipeline,
|
||||||
`allOf` so the drift guard reads one property list per schema.
|
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]
|
required: [id, request, group, policy, rewrites, route, response]
|
||||||
properties:
|
properties:
|
||||||
id: { type: integer }
|
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
|
/// Fills `buf` with the `Allow` header value for a path that matched under
|
||||||
/// other methods. The returned slice borrows `buf`.
|
/// 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 writer: std.Io.Writer = .fixed(buf);
|
||||||
var first = true;
|
var first = true;
|
||||||
for (table) |*route| {
|
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.
|
/// 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
|
/// 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.
|
/// 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;
|
if (!state.live_hash.enabled(io)) return true;
|
||||||
const sessions = state.sessions orelse return false;
|
const sessions = state.sessions orelse return false;
|
||||||
const cookie = http_util.cookieValue(request.cookie, auth.cookie_name) 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
|
/// 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.
|
/// 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 limiter = state.limiter orelse return .ok;
|
||||||
const now = std.Io.Clock.awake.now(io);
|
const now = std.Io.Clock.awake.now(io);
|
||||||
return limiter.check(io, now, address.NetAddress.fromIp(request.client_addr));
|
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
|
/// parameters fall outside the grammar is unusable and refuses. An empty
|
||||||
/// header (or one the connection budget dropped) reads as identity-only,
|
/// header (or one the connection budget dropped) reads as identity-only,
|
||||||
/// which degrades to the uncompressed entry.
|
/// 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 gzip_entry: ?bool = null;
|
||||||
var wildcard_entry: ?bool = null;
|
var wildcard_entry: ?bool = null;
|
||||||
var tokens = std.mem.splitScalar(u8, header, ',');
|
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).
|
/// 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,
|
/// Weak validators compare by content: a `W/` prefix on the wire still matches,
|
||||||
/// because the bytes behind a content hash are the content.
|
/// 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, ',');
|
var tokens = std.mem.splitScalar(u8, header, ',');
|
||||||
while (tokens.next()) |token| {
|
while (tokens.next()) |token| {
|
||||||
var candidate = std.mem.trim(u8, token, " \t");
|
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
|
/// any segment could escape the root. Segments were split before percent
|
||||||
/// decoding, so a decoded segment may contain `/` — that and `..` are the two
|
/// decoding, so a decoded segment may contain `/` — that and `..` are the two
|
||||||
/// traversal shapes, and both are refused rather than normalized.
|
/// 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..];
|
if (segments.len == 0) return index_path[1..];
|
||||||
var writer: std.Io.Writer = .fixed(buf);
|
var writer: std.Io.Writer = .fixed(buf);
|
||||||
for (segments, 0..) |segment, index| {
|
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`.
|
/// 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";
|
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 body_buf: [8192]u8 = undefined;
|
||||||
var conn: Conn = undefined;
|
var conn: Conn = undefined;
|
||||||
try conn.connect(io, env.addr);
|
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);
|
var response = try conn.receive(&body_buf);
|
||||||
try testing.expectEqual(@as(u16, 200), response.status);
|
try testing.expectEqual(@as(u16, 200), response.status);
|
||||||
|
|
||||||
// Every class of configuration write answers the one envelope.
|
// Every configuration write answers the one envelope — enumerated, not
|
||||||
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
|
// sampled. The cases come from the contract table because each entry
|
||||||
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
|
// carries a target and a body the handler would accept: a request the
|
||||||
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
|
// handler would reject anyway could answer 400 and still look like a pass.
|
||||||
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
|
var enumerated: usize = 0;
|
||||||
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
|
for (contract) |entry| {
|
||||||
};
|
if (entry.policy != .config_write) continue;
|
||||||
for (writes) |write| {
|
enumerated += 1;
|
||||||
try conn.request(write.method, write.target, null, write.body);
|
|
||||||
|
try conn.request(@tagName(entry.method), entry.target, null, entry.body);
|
||||||
response = try conn.receive(&body_buf);
|
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.expectEqual(@as(u16, 403), response.status);
|
||||||
try testing.expectEqualStrings(managed_body, response.body);
|
try testing.expectEqualStrings(managed_body, response.body);
|
||||||
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
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.
|
// Rejected before the handler, not after it: the group was never created.
|
||||||
try conn.request("GET", "/api/groups", null, null);
|
try conn.request("GET", "/api/groups", null, null);
|
||||||
response = try conn.receive(&body_buf);
|
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 } });
|
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
||||||
defer env.destroy();
|
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 {
|
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" {
|
test "drift guard c: the query-log schemas match the structs that serialize them" {
|
||||||
const gpa = testing.allocator;
|
const gpa = testing.allocator;
|
||||||
try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow");
|
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.QueryDetail, "QueryDetail");
|
||||||
try expectSchemaMatches(gpa, provenance_view.Provenance, "Provenance");
|
|
||||||
try expectSchemaMatches(gpa, coverage_mod.Coverage, "Coverage");
|
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
|
// The same drift behind a `$ref`, where the property carries no `type:` of
|
||||||
// its own: a nested object the server may now omit.
|
// its own: a nested object the server may now omit.
|
||||||
const NullableObject = struct {
|
const NullableObject = struct {
|
||||||
|
id: i64,
|
||||||
request: provenance_view.Request,
|
request: provenance_view.Request,
|
||||||
group: ?provenance_view.Group,
|
group: ?provenance_view.Group,
|
||||||
policy: provenance_view.Policy,
|
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,
|
route: provenance_view.Route,
|
||||||
response: provenance_view.Response,
|
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.
|
// And behind a `$ref` to an enum, whose values would still line up.
|
||||||
const NullableEnum = struct {
|
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
|
/// 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
|
/// 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.
|
/// 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{
|
const contract_sample_walk = [_]ContractSample{
|
||||||
.{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 },
|
.{ .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 },
|
.{ .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
|
// Blocklists. The row is created disabled so the refresh below has a status
|
||||||
// to report and still downloads nothing.
|
// 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 = "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 = "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_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 },
|
.{ .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.
|
// 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 = "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 = "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 = "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 = "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 },
|
.{ .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`.
|
// 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 = "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 = "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 = "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 },
|
.{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 },
|
||||||
|
|
||||||
// Local records.
|
// 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 = "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 = "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 },
|
.{ .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.
|
// 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 = "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 = "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 },
|
.{ .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).
|
// 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 = "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 = "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 = "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 },
|
.{ .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.
|
// conflict sample below can collide with it.
|
||||||
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
|
.{ .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 = "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 },
|
.{ .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
|
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
|
||||||
|
|||||||
Reference in New Issue
Block a user