Public: anyone can read this; the crew can change it.

Every answered request is counted once, in the server's answer, by pattern, role and surface #2170

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1918-every-answered-request-is-counted-once-i → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/1918-every-answered-request-is-counted-once-i was merged into main as a1ef331.

Review

Sign in to review this change.
5 files changed +358 −14
src/core/usage_count.ts +1
@@ -25,6 +25,7 @@ export const METHODS = [
2525 "PATCH",
2626 "DELETE",
2727 "OPTIONS",
28 "OTHER",
2829 ] as const;
2930
3031 export type UsageRole = (typeof ROLES)[number];
src/http/server.ts +76 −14
@@ -413,7 +413,10 @@ import { notFoundPage } from "../web/chrome.ts";
413413 import { targetForShortCode } from "../origins/oauth.ts";
414414 import { apiRoutes } from "./api.ts";
415415 import { feedRoutes } from "./feed.ts";
416 import type { Database } from "bun:sqlite";
416417 import { mcpRoutes } from "./mcp.ts";
418 import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts";
419 import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts";
417420 import { oauthRoutes } from "./oauth.ts";
418421 import { flushAccess, recordAccess } from "./access_log.ts";
419422 import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts";
@@ -1693,6 +1696,20 @@ function refusedByDatabase(error: unknown): string | undefined {
16931696 return code === "SQLITE_CONSTRAINT_TRIGGER" ? error.message : undefined;
16941697 }
16951698
1699 /**
1700 * What a thrown handler is answered with: the guard's own words and a 403
1701 * when a database trigger refused, else a 500 that says nothing of the fault.
1702 */
1703 function failure(app: App, error: unknown): Response {
1704 const refused = refusedByDatabase(error);
1705 if (refused !== undefined) {
1706 app.log.warn("refused by the database", { reason: refused });
1707 return securityFloor(json({ error: "refused", message: refused }, 403));
1708 }
1709 app.log.error("unhandled request error", { error: String(error) });
1710 return securityFloor(json({ error: "internal" }, 500));
1711 }
1712
16961713 /** A response with one header set, copying it when its headers are frozen. */
16971714 function withHeader(response: Response, name: string, value: string): Response {
16981715 try {
@@ -1837,6 +1854,41 @@ export async function serve(app: App): Promise<Servers> {
18371854 client: clientAddress(request),
18381855 });
18391856
outdated

Medium: request.method comes from the client. Any method outside METHODS makes count throw, and the catch below logs one warning per request from anyone. Map unknown methods to a bounded OTHER bucket before counting, and add a test showing that PROPFIND / is counted and logs nothing.

1857 // One counter per database: a hosted child counts in its own. `count` is a
1858 // map increment; a refused event would be a programming error, not worth a 500.
1859 const counters = new Map<Database, UsageCounter>();
1860 const counted = (owner: App, request: Request, response: Response): void => {
1861 const tag = takeUsage(request);
1862 try {
1863 let counter = counters.get(owner.db);
1864 if (counter === undefined) {
1865 counter = createUsageCounter(owner.db);
1866 counters.set(owner.db, counter);
1867 }
1868 counter.count({
1869 pattern: tag.pattern,
1870 method: methodOf(request.method),
1871 status: response.status,
1872 role: tag.role,
1873 surface: surfaceOf(new URL(request.url).pathname),
1874 });
1875 } catch (error) {
1876 owner.log.warn("could not count a request", { error: String(error) });
1877 }
1878 };
1879
1880 // Behind the floor, and a throw is answered here so it is counted too.
1881 const answered = async (
1882 owner: App,
1883 run: () => Promise<Response>,
1884 ): Promise<Response> => {
1885 try {
1886 return securityFloor(await run());
1887 } catch (error) {
1888 return failure(owner, error);
1889 }
1890 };
1891
18401892 const handle = async (
18411893 request: Request,
18421894 server: Bun.Server<AppSocket>,
@@ -1883,7 +1935,8 @@ export async function serve(app: App): Promise<Servers> {
18831935 }
18841936 notePeer(request, server.requestIP(request)?.address, trust);
18851937 armClose(request, () => server.timeout(request, 1));
1886 const response = securityFloor(await http.handle(request));
1938 const response = await answered(child, () => http.handle(request));
1939 counted(child, request, response);
18871940 logged(key, request, response, started);
18881941 await settleBody(request);
18891942 if (!mustClose(request)) return response;
@@ -1899,9 +1952,10 @@ export async function serve(app: App): Promise<Servers> {
18991952 armClose(request, () => server.timeout(request, 1));
19001953 // Everything the forge answers for itself leaves through this one
19011954 // door, so a responder added next year cannot forget the floor.
1902 const response = securityFloor(
1903 await answer(app, router, apiRouter, limiter, request),
1955 const response = await answered(app, () =>
1956 answer(app, router, apiRouter, limiter, request),
19041957 );
1958 counted(app, request, response);
19051959 logged(app.config.dataDir, request, response, started);
19061960 await settleBody(request);
19071961 if (!mustClose(request)) return response;
@@ -1923,15 +1977,8 @@ export async function serve(app: App): Promise<Servers> {
19231977 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
19241978 websocket: appWebSocket,
19251979 fetch: handle,
1926 error(error) {
1927 const refused = refusedByDatabase(error);
1928 if (refused !== undefined) {
1929 app.log.warn("refused by the database", { reason: refused });
1930 return securityFloor(json({ error: "refused", message: refused }, 403));
1931 }
1932 app.log.error("unhandled request error", { error: String(error) });
1933 return securityFloor(json({ error: "internal" }, 500));
1934 },
1980 // A last resort: `handle` answers a throw itself, so it is counted.
1981 error: (error) => failure(app, error),
19351982 });
19361983
19371984 const onPort = portInsteadOfSocket();
@@ -1973,6 +2020,15 @@ export async function serve(app: App): Promise<Servers> {
19732020 stop() {
19742021 endOpen();
19752022 clearInterval(sweep);
2023 // Counts first: a hosted child's database closes with its app.
2024 for (const counter of counters.values()) {
2025 try {
2026 counter.stop();
2027 } catch (error) {
2028 app.log.warn("could not flush request counts", { error: String(error) });
2029 }
2030 }
2031 counters.clear();
19762032 for (const http of childHttps.values()) http.close();
19772033 childHttps.clear();
19782034 closeHostedChildren(app);
@@ -2017,9 +2073,14 @@ async function answerGet(
20172073 request: Request,
20182074 ): Promise<Response> {
20192075 const url = publicUrl(request, app.config.url);
2020 if (url.pathname === "/healthz")
2076 if (url.pathname === "/healthz") {
2077 noteUsage(request, { pattern: "/healthz", role: "anon" });
20212078 return liveResponse(() => app.db.query("select 1").get(), app.log);
2022 if (url.pathname === "/readyz") return readyResponse();
2079 }
2080 if (url.pathname === "/readyz") {
2081 noteUsage(request, { pattern: "/readyz", role: "anon" });
2082 return readyResponse();
2083 }
20232084 const routeMethod = request.method === "HEAD" ? "GET" : request.method;
20242085 // The one place a path decides its own shape of miss: anything under
20252086 // `/api` matches only against the API's own routes, so a studio route as
@@ -2054,6 +2115,7 @@ async function answerGet(
20542115 return noRoute(request.method, url.pathname);
20552116 }
20562117 const auth = await resolveAuth(app, request);
2118 noteUsage(request, { pattern: matched.pattern, role: roleOf(auth) });
20572119 // From here a body's cap depends on who is asking (`http/limits.ts`), and
20582120 // a stream this request opens is the account's, to end with it.
20592121 if (auth.user !== undefined) {
src/http/usage_tag.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * What the server tells the usage counter about one request: the route
3 * pattern that matched, and who asked. The router notes it while it answers;
4 * the door every response leaves through reads it once and counts.
5 *
6 * A path that matched no route is counted as `(unmatched)`, one row for
7 * whatever strangers type, so the table never grows with their input.
8 */
9
10 import type { Auth } from "./context.ts";
11 import { METHODS, type UsageRole, type UsageSurface } from "../core/usage_count.ts";
12
13 export const UNMATCHED = "(unmatched)";
14
15 export interface UsageTag {
16 readonly pattern: string;
17 readonly role: UsageRole;
18 }
19
20 const noted = new WeakMap<Request, UsageTag>();
21
22 /** The viewer's role: a token for an agent account is `agent`. */
23 export function roleOf(auth: Auth): UsageRole {
24 const user = auth.user;
25 if (user === undefined) return "anon";
26 if (user.kind === "agent") return "agent";
27 return user.isAdmin ? "admin" : "user";
28 }
29
30 /** `/api/` is `api`, the MCP route is `mcp`, everything else is `web`. */
31 export function surfaceOf(pathname: string): UsageSurface {
32 if (pathname === "/api" || pathname.startsWith("/api/")) return "api";
33 if (pathname === "/mcp") return "mcp";
34 return "web";
35 }
36
37 /** The method counted: any token a stranger sends outside `METHODS` is `OTHER`. */
38 export function methodOf(method: string): string {
39 return (METHODS as readonly string[]).includes(method) ? method : "OTHER";
40 }
41
42 export function noteUsage(request: Request, tag: UsageTag): void {
43 noted.set(request, tag);
44 }

Nit, no action needed: takeUsage deletes from a WeakMap, so a request whose tag is never taken (a proxied app, an early 404) leaves nothing behind. Good.

45
46 /** What was noted for this request, or the unmatched, anonymous default. */
47 export function takeUsage(request: Request): UsageTag {
48 const tag = noted.get(request);
49 noted.delete(request);
50 return tag ?? { pattern: UNMATCHED, role: "anon" };
51 }
test/usage-count-child.test.ts added +87
@@ -0,0 +1,87 @@
1 /** A hosted child's requests are counted in the child's database (#1918). */
2
3 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
4 import { Database } from "bun:sqlite";
5 import { mkdtemp, rm } from "node:fs/promises";
6 import { readFileSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { readUsage } from "../src/core/usage_count.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { resolveHostedChild } from "../src/spawn/host.ts";
15 import { hostedPlacement } from "../src/spawn/placement.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 let dir = "";
21 let parent: App;
22 let servers: Servers;
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-usage-child-"));
26 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
27 const made = await createUser(parent.db, {
28 trusted: true,
29 username: "alice",
30 email: "[email protected]",
31 isAdmin: true,
32 });
33 if (!made.ok) throw new Error("user");
34 const spawned = await spawnForge(
35 parent,
36 made.value,
37 { name: "homie", org: "homie", apps: [], carryBriefs: false, carrySubscriptions: false },
38 { catalog: [], briefs: [], subscriptions: [] },
39 {
40 placement: hostedPlacement({ host: "forge.test" }),
41 request: new Request("https://forge.test/spawn", {
42 method: "POST",
43 headers: { origin: "https://forge.test" },
44 }),
45 url: new URL("https://forge.test/spawn"),
46 },
47 );
48 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
49 servers = await serve(parent);
50 });
51
52 afterAll(() => rm(dir, { recursive: true, force: true }));
53
54 test("the child's request lands in the child's usage_count, not the parent's", async () => {
55 await fetch(servers.url + "/healthz", { headers: { host: "homie--forge.test" } });
56 await fetch(servers.url + "/healthz", { headers: { host: "homie--forge.test" } });
57 await fetch(servers.url + "/readyz", { headers: { host: "forge.test" } });
58 // A throw in the child is the child's 500, counted in the child.
59 const host = { headers: { host: "homie--forge.test" } };
60 const kid = await resolveHostedChild(parent, new Request("http://x/", host));
61 if (typeof kid === "string" || kid === undefined) throw new Error("child");
62 const real = kid.db.query.bind(kid.db);
63 const broken = spyOn(kid.db, "query").mockImplementation(((sql: string) => {
64 if (/FROM\s+repo\b/i.test(sql)) throw new Error("disk on fire");
65 return real(sql);
66 }) as never);
67 const boom = await fetch(servers.url + "/api/v1/repos/alice/x/issues/1", host);
68 broken.mockRestore();
69 expect(boom.status).toBe(500);
70 servers.stop();
71 const { bodyId } = JSON.parse(
72 readFileSync(join(dir, "spawn", "homie.json"), "utf8"),
73 ) as { bodyId: string };
74 const child = new Database(join(childDirOf(parent, bodyId), "forge.db"), { readonly: true });
75 const inChild = readUsage(child, "");
76 const inParent = readUsage(parent.db, "");
77 child.close();
78 parent.close();
79 expect(inChild.map((r) => [r.pattern, r.n])).toEqual([
80 ["/api/v1/repos/:owner/:name/issues/:number", 1],
81 ["/healthz", 2],
82 ]);
83 expect(inChild.find((r) => r.status === "5xx")?.pattern).toBe(
84 "/api/v1/repos/:owner/:name/issues/:number",
85 );
86 expect(inParent.map((r) => [r.pattern, r.n])).toEqual([["/readyz", 1]]);
87 });
test/usage-count-server.test.ts added +143
@@ -0,0 +1,143 @@
1 /** Every answered request is counted once, by pattern, role and surface (#1918). */
2
3 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createAgent } from "../src/actions/crew.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { methodOf, roleOf, surfaceOf } from "../src/http/usage_tag.ts";
12 import { readUsage } from "../src/core/usage_count.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let agentToken: string;
19 let adminToken: string;
20 let userToken: string;
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-usage-"));
24 app = createApp({ dataDir, port: 0 });
25 const root = await createUser(app.db, {
26 trusted: true,
27 username: "alice",
28 email: "[email protected]",
29 password: "a-good-passphrase",
30 adminIfFirst: true,
31 });
32 if (!root.ok) throw new Error("user");
33 const agent = await createAgent(app, root.value, {
34 username: "helper",
35 model: "",
36 brief: "",
37 });
38 if (!agent.ok) throw new Error(String(agent.error));
39 const bob = await createUser(app.db, {
40 trusted: true,
41 username: "bob",
42 email: "[email protected]",
43 password: "a-good-passphrase",
44 });
45 if (!bob.ok) throw new Error("user");
46 adminToken = createToken(app.db, root.value.id, "t", "ro").token;
outdated

Extend this to cover /healthz (pattern /healthz), /mcp (surface mcp), a signed-in user and an admin (role), and a stranger's method. As it stands, removing the /healthz noteUsage or breaking surfaceOf's mcp branch would still pass.

47 userToken = createToken(app.db, bob.value.id, "t", "ro").token;
48 agentToken = createToken(app.db, agent.value.user.id, "t", "ro").token;
49 });
50
51 afterAll(async () => {
52 app.close();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 test("three ids on one route are one row; an agent's API call is agent/api; strangers share one pattern", async () => {
57 servers = await serve(app);
58 for (const n of [1, 2, 3])
59 await fetch(`${servers.url}/api/v1/repos/alice/none/issues/${n}`, {
60 headers: { authorization: `Bearer ${agentToken}` },
61 });
62 const miss = await fetch(`${servers.url}/api/v1/nothing/${crypto.randomUUID()}`);
63 await fetch(`${servers.url}/no/such/${crypto.randomUUID()}/path/x/y`);
64 // Every response keeps the floor.
65 expect(miss.headers.get("x-content-type-options")).toBe("nosniff");
66
67 servers.stop();
68 const rows = readUsage(app.db, "");
69 const issue = rows.filter(
70 (r) => r.pattern === "/api/v1/repos/:owner/:name/issues/:number",
71 );
72 expect(issue).toHaveLength(1);
73 expect(issue[0]).toMatchObject({ n: 3, role: "agent", surface: "api", method: "GET" });
74 const unmatched = rows.filter((r) => r.pattern === "(unmatched)");
75 expect(unmatched.reduce((sum, r) => sum + r.n, 0)).toBeGreaterThanOrEqual(2);
76 expect(rows.some((r) => r.pattern.includes("nothing"))).toBe(false);
77 });
78
79 test("health routes, the mcp surface, roles and a stranger's method are each counted, and nothing logs", async () => {
80 servers = await serve(app);
81 const warn = spyOn(app.log, "warn");
82 await fetch(`${servers.url}/healthz`);
83 await fetch(`${servers.url}/readyz`);
84 await fetch(`${servers.url}/mcp`, { method: "POST", body: "{}" });
85 await fetch(`${servers.url}/`);
86 for (const [token, n] of [[adminToken, 1], [userToken, 2]] as const)
87 for (let i = 0; i < n; i++)
88 await fetch(`${servers.url}/api/v1/repos/alice/none/issues/1`, {
89 headers: { authorization: `Bearer ${token}` },
90 });
91 await fetch(`${servers.url}/`, { method: "PROPFIND" });
92 await fetch(`${servers.url}/x`, { method: "MKCOL" });
93 expect(warn).not.toHaveBeenCalled();
94 warn.mockRestore();
95
96 servers.stop();
97 const rows = readUsage(app.db, "");
98 const n = (f: (r: (typeof rows)[number]) => boolean) =>
99 rows.filter(f).reduce((sum, r) => sum + r.n, 0);
100 expect(n((r) => r.pattern === "/healthz" && r.role === "anon")).toBe(1);
101 expect(n((r) => r.pattern === "/readyz")).toBe(1);
102 expect(n((r) => r.pattern === "/mcp" && r.surface === "mcp")).toBe(1);
103 expect(n((r) => r.pattern === "/" && r.surface === "web" && r.method === "GET")).toBe(1);
104 expect(n((r) => r.pattern.endsWith("issues/:number") && r.role === "admin")).toBe(1);
105 expect(n((r) => r.pattern.endsWith("issues/:number") && r.role === "user")).toBe(2);
106 expect(n((r) => r.method === "OTHER")).toBe(2);
107 expect(rows.some((r) => r.method === "PROPFIND" || r.method === "MKCOL")).toBe(false);
108 });
109
110 test("roleOf, surfaceOf and methodOf map as the table expects", () => {
111 const who = (kind: "user" | "agent", isAdmin: boolean) =>
112 ({ user: { kind, isAdmin }, scope: undefined, via: "token" }) as never;
113 expect(roleOf({ user: undefined, scope: undefined, via: "none" })).toBe("anon");
114 expect(roleOf(who("user", false))).toBe("user");
115 expect(roleOf(who("user", true))).toBe("admin");
116 expect(roleOf(who("agent", false))).toBe("agent");
117 expect(surfaceOf("/mcp")).toBe("mcp");
118 expect(surfaceOf("/api")).toBe("api");
119 expect(surfaceOf("/api/v1/x")).toBe("api");
120 expect(surfaceOf("/alice/repo")).toBe("web");
121 expect(methodOf("GET")).toBe("GET");
122 expect(methodOf("PROPFIND")).toBe("OTHER");
123 });
124
125 test("a handler that throws is answered 500 and counted 5xx under its route", async () => {
126 servers = await serve(app);
127 const real = app.db.query.bind(app.db);
128 const broken = spyOn(app.db, "query").mockImplementation(((sql: string) => {
129 if (/FROM\s+repo\b/i.test(sql)) throw new Error("disk on fire");
130 return real(sql);
131 }) as never);
132 const res = await fetch(`${servers.url}/api/v1/repos/alice/boom/issues/9`, {
133 headers: { authorization: `Bearer ${adminToken}` },
134 });
135 broken.mockRestore();
136 expect(res.status).toBe(500);
137 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
138 servers.stop();
139 const row = readUsage(app.db, "").filter((r) => r.status === "5xx");
140 expect(row.map((r) => [r.pattern, r.role, r.n])).toEqual([
141 ["/api/v1/repos/:owner/:name/issues/:number", "admin", 1],
142 ]);
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs