//! The route table. //! //! Deliberately its own file: `router.zig` owns matching and dispatch, and the //! entries are filled in by the session that writes the handlers (milestone 8, //! session W8). Ruling 23's drift guards read `router.routes`, which is this //! array re-exported, so the contract test and the router can never disagree //! about what the server serves. //! //! Adding a route means adding one entry here — and documenting it in //! openapi.yaml, which openapi.zig's tests and W10's drift guards enforce. //! Nothing else in the web layer knows the path set. //! //! Policy columns restate two rulings as data: `auth = .open` is exactly //! ruling 18's exemption list (monitoring endpoints, the contract, the login //! itself), and `rate_limit = .exempt` is ruling 19's (Prometheus must never //! see 429) plus the live stream, which holds one request across its whole //! life and is bounded by the SSE per-address cap instead of the token //! bucket. The static assets are ruling 18's remaining exemption; they are //! not routes — the router sends unmatched non-`/api` paths to //! `WebState.fallback` before any policy check. //! //! `policy` is the third such column, and milestone-20 ruling 7's contract: //! under file authority the file is the sole declarative source, so a //! `config_write` answers 403 and a `runtime_action` stays live. It has no //! default value on purpose — a route added without a stated class must not //! inherit one. Classification is per route, not per prefix: //! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its //! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a //! `runtime_action` here because deleting an *observed* row discards runtime //! state the file never declared; the declared case needs a row read and the //! clients handler answers it. const router = @import("router.zig"); const auth = @import("handlers/auth.zig"); const blocklists = @import("handlers/blocklists.zig"); const certs = @import("handlers/certs.zig"); const clients = @import("handlers/clients.zig"); const diagnostics = @import("handlers/diagnostics.zig"); const groups = @import("handlers/groups.zig"); const health = @import("handlers/health.zig"); const live = @import("handlers/live.zig"); const local = @import("handlers/local.zig"); const lookup = @import("handlers/lookup.zig"); const metrics = @import("metrics.zig"); const openapi = @import("openapi.zig"); const pause = @import("handlers/pause.zig"); const queries = @import("handlers/queries.zig"); const rules = @import("handlers/rules.zig"); const settings = @import("handlers/settings.zig"); const stats = @import("handlers/stats.zig"); const upstream_health = @import("handlers/upstream_health.zig"); const upstreams = @import("handlers/upstreams.zig"); const version = @import("handlers/version.zig"); pub const table: []const router.RouteInfo = &.{ // Monitoring and contract (ruling 18's open set, ruling 19's exemptions). .{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt }, .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt }, .{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle }, .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle }, // Authentication. .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login }, .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout }, // Query log, stats, live stream, lookup. .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list }, .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt }, .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals }, .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries }, .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle }, .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle }, // Diagnostics: the operational event log (milestone 27). The two purges are // `runtime_action` — the event log is runtime state no configuration file // declares, so file authority has nothing to say about deleting from it. .{ .method = .GET, .pattern = "/api/diagnostics", .auth = .session, .policy = .read, .handler = diagnostics.list }, .{ .method = .DELETE, .pattern = "/api/diagnostics", .auth = .session, .policy = .runtime_action, .handler = diagnostics.purgeAll }, .{ .method = .GET, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .read, .handler = diagnostics.get }, .{ .method = .DELETE, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .runtime_action, .handler = diagnostics.purge }, // Groups. .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list }, .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create }, .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get }, .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update }, .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove }, .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources }, .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources }, // Blocklist sources. `/api/blocklists/update` is a literal segment; it // cannot collide with `{id}`, which only matches a positive integer. .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list }, .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create }, .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh }, .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get }, .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update }, .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove }, // Rules. .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list }, .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create }, .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get }, .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update }, .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove }, // Local records. .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords }, .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord }, .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord }, .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord }, .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord }, // Forward zones. .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones }, .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone }, .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone }, .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone }, .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone }, // Clients (no POST — rows come from DNS activity or import, ruling 9). .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list }, .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get }, .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update }, .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove }, .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes }, .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes }, // Upstreams (restart-required resource). .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list }, .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create }, .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get }, .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update }, .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove }, // Pause and settings. .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get }, .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post }, .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get }, .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put }, // Certificates (milestone-10 ruling 8). .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post }, }; // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const std = @import("std"); const testing = std.testing; test "the table carries every endpoint of the milestone" { try testing.expectEqual(@as(usize, 60), table.len); } test "no two entries claim the same method and pattern" { for (table, 0..) |a, i| { for (table[i + 1 ..]) |b| { if (a.method != b.method) continue; try testing.expect(!std.mem.eql(u8, a.pattern, b.pattern)); } } } test "every pattern lives under /api except the Prometheus endpoint" { for (table) |route| { if (std.mem.eql(u8, route.pattern, "/metrics")) continue; try testing.expect(std.mem.startsWith(u8, route.pattern, "/api/")); } } test "the open set is exactly ruling 18's exemption list" { const open = [_][]const u8{ "/metrics", "/api/health", "/api/version", "/api/openapi.yaml", "/api/auth/login", }; var found: usize = 0; for (table) |route| { if (route.auth != .open) continue; found += 1; var listed = false; for (open) |pattern| listed = listed or std.mem.eql(u8, route.pattern, pattern); try testing.expect(listed); } try testing.expectEqual(open.len, found); } test "the limiter exemptions are the monitoring endpoints and the live stream" { const exempt = [_][]const u8{ "/metrics", "/api/health", "/api/queries/live", }; var found: usize = 0; for (table) |route| { if (route.rate_limit != .exempt) continue; found += 1; var listed = false; for (exempt) |pattern| listed = listed or std.mem.eql(u8, route.pattern, pattern); try testing.expect(listed); } try testing.expectEqual(exempt.len, found); } test "the config writes are exactly the declarative mutations" { const writes = [_][]const u8{ "POST /api/groups", "PUT /api/groups/{id}", "DELETE /api/groups/{id}", "PUT /api/groups/{id}/sources", "POST /api/blocklists", "PUT /api/blocklists/{id}", "DELETE /api/blocklists/{id}", "POST /api/rules", "PUT /api/rules/{id}", "DELETE /api/rules/{id}", "POST /api/local-records", "PUT /api/local-records/{id}", "DELETE /api/local-records/{id}", "POST /api/forward-zones", "PUT /api/forward-zones/{id}", "DELETE /api/forward-zones/{id}", "PUT /api/clients/{id}", "PUT /api/client-prefixes", "POST /api/upstreams", "PUT /api/upstreams/{id}", "DELETE /api/upstreams/{id}", "PUT /api/settings", }; try expectClass(.config_write, &writes); } test "the runtime actions are exactly ruling 7's list" { const actions = [_][]const u8{ "POST /api/auth/login", "POST /api/auth/logout", "POST /api/blocklists/update", "DELETE /api/diagnostics", "DELETE /api/diagnostics/{id}", "DELETE /api/clients/{id}", "POST /api/pause", "POST /api/certs/reload", }; try expectClass(.runtime_action, &actions); } test "every read is a GET and every GET is a read" { for (table) |route| { try testing.expectEqual(route.method == .GET, route.policy == .read); } } /// Asserts that the routes classified `policy` are exactly `expected`, each /// written `METHOD /pattern`. fn expectClass(policy: router.Policy, expected: []const []const u8) !void { var buf: [64]u8 = undefined; var found: usize = 0; for (table) |route| { if (route.policy != policy) continue; found += 1; const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern }); var listed = false; for (expected) |name| listed = listed or std.mem.eql(u8, label, name); if (!listed) { std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy }); return error.TestUnexpectedResult; } } try testing.expectEqual(expected.len, found); } test "item routes capture one id and collection routes capture none" { for (table) |route| { const captures = std.mem.count(u8, route.pattern, "{id}"); try testing.expect(captures <= 1); if (captures == 1) { try testing.expect(route.method == .GET or route.method == .PUT or route.method == .DELETE); } } }