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

Every admin-only action refuses the everyday account's session: one test walks the route table and names any that does not #11840

Merged opened this

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

Merged

scout/forge:scout/11818-every-admin-only-action-refuses-the-ever → main 9 commits fork 1 thread · 1 unresolved

  • Merged. scout/11818-every-admin-only-action-refuses-the-ever was merged into main as 10a971a.

Review

Sign in to review this change.
94 files changed +888 −180
docs/ROLES.md +13
@@ -86,6 +86,19 @@ can change the forge's settings or its releases" (`not_admin` in the API).
8686 `src/http/admin_gate.ts` is the one gate, ahead of every handler;
8787 `/releases.json` stays public. Token scopes are a later part.
8888
89 Since #11818 the gate guards every route `src/http/route_marks.ts` marks
90 `admin` — the admin's pages under `/settings`, `/forges`, `/system`,
91 `/maintenance` and the rest, not only `/admin` — and answers with one
92 sentence that says what to do next: "This is for the forge's admin account,
93 so sign in as admin to do it." An admin-only MCP tool called by name from an
94 everyday account gets the same sentence. Every route either router registers
95 is marked `admin` or `open` there; `test/route-marks.test.ts` fails on one
96 marked neither, and calls each admin route and tool as an everyday account
97 and as the admin. A head holding both kinds of route is `mixed`, so each of
98 its routes is listed, and a path under it that no listed route matches is
99 treated as `admin`: a forgotten mark fails closed. The 403 does tell an
100 everyday account that an admin route or tool by that name exists.
101
89102 Nothing switches the refusal off: the test suite runs with it everywhere, and
90103 a suite whose people make things promotes them first (`promote` in
91104 `test/helpers/members.ts`). The crew's wake-up sweep, which starts a run on an
src/core/roles.ts +3
@@ -55,6 +55,9 @@ export const NOT_A_MEMBER =
5555 /** The words a member (or guest) is refused admin settings and releases with. */
5656 export const NOT_AN_ADMIN = "only an admin can change the forge's settings or its releases";
5757
58 /** The one sentence an everyday account is refused an admin-only route or tool with: why, and what to do next (#11818). */
59 export const SIGN_IN_AS_ADMIN = "This is for the forge's admin account, so sign in as admin to do it.";
60
5861 const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1);
5962 const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
6063
src/http/admin_gate.ts +26 −17
@@ -1,34 +1,43 @@
11 /**
2 * The one gate in front of admin settings and releases: `/admin/*`,
3 * `/api/v1/admin/*` and the `/releases` page with its rollback. A signed-in
4 * person whose role lacks `administer` (`core/roles.ts`) is refused with a
5 * 403 and one plain sentence, as JSON for the API and as a page for a
6 * browser, before any handler runs. Anyone else meets the handler's own
2 * The one gate in front of every admin-only route: those `route_marks.ts`
3 * marks `admin` — `/admin/*`, `/api/v1/admin/*`, `/releases` with its
4 * rollback, and the admin's pages elsewhere (`/settings/billing`, `/boards`,
5 * …). A signed-in person whose role lacks `administer` (`core/roles.ts`) is
6 * refused with a 403 and one plain sentence saying to sign in as admin, as
7 * JSON for the API and as a page for a browser, before any handler runs. Anyone else meets the handler's own
78 * rules, so an admin is unchanged. For releases the rule itself is
89 * `release/gate.ts`'s, which the terminal shares; this gate only supplies the
910 * 403 and its wording on the page. `/releases.json` stays public.
1011 */
1112
1213 import type { App } from "../app.ts";
13 import { lacks, NOT_AN_ADMIN } from "../core/roles.ts";
14 import { lacks, SIGN_IN_AS_ADMIN } from "../core/roles.ts";
1415 import { forbiddenPage } from "../web/chrome.ts";
1516 import type { Auth, Ctx } from "./context.ts";
1617 import { json } from "./respond.ts";
18 import { isAdminRoute, markOf } from "./route_marks.ts";
1719
18 const under = (path: string, root: string): boolean => path === root || path.startsWith(`${root}/`);
19
20 /** Whether this path is one the `administer` ability guards. */
20 /**
21 * Whether this path is one the `administer` ability guards: its mark in
22 * `route_marks.ts`. `/admin/flows` is marked open there: it answers anyone but
23 * a site admin with the not-found page (#9594), its own refusal.
24 */
2125 export function isAdminPath(path: string): boolean {
22 // `/admin/flows` answers anyone but a site admin with the not-found page (#9594), so it keeps its own refusal.
23 if (path === "/admin/flows") return false;
24 return under(path, "/admin") || under(path, "/api/v1/admin") || under(path, "/releases");
26 return isAdminRoute(path);
2527 }
2628
27 /** The refusal for a person who may not administer, or undefined to carry on. */
28 export function adminGate(app: App, request: Request, url: URL, auth: Auth): Response | undefined {
29 if (auth.user === undefined || !isAdminPath(url.pathname)) return undefined;
29 /**
30 * The refusal for a person who may not administer, or undefined to carry on.
31 * `pattern` is the route the router matched, so the mark is the one of the
32 * handler that would run — `/packs/demo.git/info/refs` is the git route, not
33 * a `/packs` page; without one the path's own mark decides.
34 */
35 export function adminGate(app: App, request: Request, url: URL, auth: Auth, pattern?: string): Response | undefined {
36 if (auth.user === undefined) return undefined;
37 const mark = pattern === undefined ? undefined : markOf(pattern);
38 if (!(mark === undefined ? isAdminPath(url.pathname) : mark === "admin")) return undefined;
3039 if (!lacks(app.db, auth.user, "administer")) return undefined;
31 if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: NOT_AN_ADMIN }, 403);
40 if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: SIGN_IN_AS_ADMIN }, 403);
3241 const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined };
33 return forbiddenPage(ctx, NOT_AN_ADMIN);
42 return forbiddenPage(ctx, SIGN_IN_AS_ADMIN);
3443 }
src/http/mcp.ts +4 −1
@@ -37,7 +37,7 @@ import { REPORT_RECEIVED } from "../actions/report_words.ts";
3737 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
3838 import { everydayOf } from "../core/everyday.ts";
3939 import { claimDone, completeWrite, dropWrite, recordWrite } from "../core/conversation_writes.ts";
40 import { type Args, alreadyDone, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts";
40 import { type Args, alreadyDone, notAdminTool, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts";
4141 import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts";
4242 import { TOOLS } from "./mcp/tools.ts";
4343
@@ -191,6 +191,9 @@ function rpcOne(
191191 typeof params["arguments"] === "object" && params["arguments"] !== null
192192 ? (params["arguments"] as Args)
193193 : {};
194 // An admin's tool stays out of an everyday account's list, and a call to
195 // it by name is told how to reach it before anything is held or asked (#11818).
196 if (tool?.adminOnly === true && !user.isAdmin) return refuse(-32602, notAdminTool(tool.name));
194197 // Before the scope floor, so an admin tool the forge's agent was
195198 // steered to is held as a confirm too, not merely refused (#7649), and
196199 // one the person asked for waits for their yes (#7650).
src/http/mcp/shared.ts +6 −3
@@ -1,3 +1,4 @@
1 import { SIGN_IN_AS_ADMIN } from "../../core/roles.ts";
12 import { mayView } from "../../core/sealed.ts";
23 import { getIssue, LIST_LIMIT_MAX } from "../../core/issues.ts";
34 import { canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts";
@@ -73,6 +74,9 @@ export interface Tool {
7374 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
7475 }
7576
77 /** What an everyday account calling an admin's tool by name is told: not here, and the one sentence on what to do next. */
78 export const notAdminTool = (name: string): string => `no tool named ${name} for this account. ${SIGN_IN_AS_ADMIN}`;
79
7680 /** Whether this caller is shown the tool and may call it: one rule for the manifest and dispatch. */
7781 export const visible = (tool: Tool, ctx: Ctx, user: User): boolean =>
7882 (tool.adminOnly !== true || user.isAdmin) &&
@@ -125,8 +129,6 @@ function askedWork(ctx: Ctx, user: User, via: string, tool: Tool, args: Args, wo
125129 export function heldWrite(ctx: Ctx, user: User, tool: Tool, args: Args): Record<string, unknown> | undefined {
126130 const via = ctx.auth.through;
127131 if (via === undefined || !tool.writes || plansOnly(tool, args)) return undefined;
128 // An admin-only tool stays unknown to a person who is not one.
129 if (tool.adminOnly === true && !user.isAdmin) return undefined;
130132 const words = fenceUp(ctx.app.db, via);
131133 if (words === undefined || named(words, tool.name, args) || askedWork(ctx, user, via, tool, args, words)) return undefined;
132134 if (tooBigToApprove(args)) return tooBig(tool.name);
@@ -184,7 +186,8 @@ export function askedFirst(ctx: Ctx, user: User, tool: Tool, args: Args): Record
184186 const via = ctx.auth.through;
185187 if (via === undefined) return undefined;
186188 // What the person may not do stays unknown to their agent.
187 if ((tool.adminOnly === true || tool.needsAdminScope === true) && !user.isAdmin) return undefined;
189 // An admin-only tool is refused before this, in mcp.ts (#11818).
190 if (tool.needsAdminScope === true && !user.isAdmin) return undefined;
188191 const short = tool.needsAdminScope === true && !scopeAllows(ctx.auth.scope ?? "ro", "admin");
189192 if (!short && (!tool.writes || plansOnly(tool, args) || !asksFirst(tool.name, args))) return undefined;
190193 if (tooBigToApprove(args)) return tooBig(tool.name);
src/http/route_marks.ts added +415
@@ -0,0 +1,415 @@
1 /**
2 * Which routes are an admin's, as data (#11818). Every route either router
3 * registers is marked `admin` or `open` here, by its head — the first path
4 * segment, or `/api/v1/<segment>` on the API — or, under a head marked
5 * `mixed`, by its own pattern. A head holding both kinds is `mixed`, so each
6 * of its routes chooses; a path under a `mixed` head that no listed route
7 * matches is treated as `admin`, so a forgotten mark fails closed.
8 *
9 * `admin_gate.ts` refuses a signed-in account that may not administer every
10 * path marked `admin`, before any handler runs; `open` routes keep their
11 * handler's own rules. `test/route-marks.test.ts` walks both routers and
12 * fails on a route marked neither, so a new route must choose here.
13 */
14
15 import { Router } from "./router.ts";
16
17 export type Mark = "admin" | "open";
18
19 /** Every head of a registered route: one mark for all of it, or `mixed` when each route is listed below. */
20 export const HEADS: Readonly<Record<string, Mark | "mixed">> = {
21 "/": "open",
22 "/-": "open",
23 "/.well-known": "open",
24 "/:owner": "open",
25 "/__forge": "open",
26 "/_funnel": "open",
27 "/_measure": "open",
28 "/_script-error": "open",
29 "/admin": "mixed",
30 "/agents": "open",
31 "/api": "open",
32 "/api/v1": "open",
33 "/api/v1/": "open",
34 "/api/v1/admin": "admin",
35 "/api/v1/agents": "open",
36 "/api/v1/app-templates": "open",
37 "/api/v1/apps": "open",
38 "/api/v1/asked": "open",
39 "/api/v1/asks": "open",
40 "/api/v1/boards": "open",
41 "/api/v1/builds": "open",
42 "/api/v1/changelog": "open",
43 "/api/v1/cloud": "open",
44 "/api/v1/codes": "open",
45 "/api/v1/contribute": "open",
46 "/api/v1/conversation": "open",
47 "/api/v1/conversation-confirms": "open",
48 "/api/v1/conversation-tokens": "open",
49 "/api/v1/conversations": "open",
50 "/api/v1/credit": "open",
51 "/api/v1/crew": "open",
52 "/api/v1/devices": "open",
53 "/api/v1/drills": "open",
54 "/api/v1/explore": "open",
55 "/api/v1/family": "open",
56 "/api/v1/features": "open",
57 "/api/v1/feed": "open",
58 "/api/v1/first-admin": "open",
59 "/api/v1/flags": "open",
60 "/api/v1/fleet": "open",
61 "/api/v1/forge": "open",
62 "/api/v1/forges": "open",
63 "/api/v1/front": "open",
64 "/api/v1/health": "open",
65 "/api/v1/heartbeat": "open",
66 "/api/v1/here": "open",
67 "/api/v1/keeper": "open",
68 "/api/v1/maintenance": "open",
69 "/api/v1/map": "open",
70 "/api/v1/migrate": "open",
71 "/api/v1/openapi.json": "open",
72 "/api/v1/orgs": "open",
73 "/api/v1/packs": "open",
74 "/api/v1/passport": "open",
75 "/api/v1/plans": "open",
76 "/api/v1/release": "open",
77 "/api/v1/releases": "open",
78 "/api/v1/repos": "open",
79 "/api/v1/returns": "open",
80 "/api/v1/search": "open",
81 "/api/v1/secrets": "open",
82 "/api/v1/sessions": "open",
83 "/api/v1/share": "open",
84 "/api/v1/start": "open",
85 "/api/v1/telemetry": "open",
86 "/api/v1/thoughts": "open",
87 "/api/v1/timers": "open",
88 "/api/v1/tokens": "open",
89 "/api/v1/usage": "open",
90 "/api/v1/user": "open",
91 "/api/v1/users": "open",
92 "/api/v1/version": "open",
93 "/apple-touch-icon-precomposed.png": "open",
94 "/apple-touch-icon.png": "open",
95 "/apps": "open",
96 "/asked": "open",
97 "/asks": "open",
98 "/assets": "open",
99 "/audit": "open",
100 "/audit.csv": "open",
101 "/big": "open",
102 "/board": "open",
103 "/boards": "mixed",
104 "/bring": "open",
105 "/changelog": "open",
106 "/changelog.atom": "open",
107 "/claim": "open",
108 "/contract": "open",
109 "/contribute": "open",
110 "/csp-report": "open",
111 "/data": "open",
112 "/deploy-to": "admin",
113 "/displays": "open",
114 "/docs": "open",
115 "/events": "open",
116 "/explore": "open",
117 "/explore.atom": "open",
118 "/family": "open",
119 "/favicon.ico": "open",
120 "/feed": "open",
121 "/first-admin": "open",
122 "/fleet": "open",
123 "/flow": "mixed",
124 "/forges": "mixed",
125 "/front": "open",
126 "/go": "open",
127 "/home": "open",
128 "/hooks": "open",
129 "/icons": "open",
130 "/live": "mixed",
131 "/llms.txt": "open",
132 "/login": "open",
133 "/logout": "open",
134 "/maintenance": "admin",
135 "/manifest.webmanifest": "open",
136 "/map": "open",
137 "/mcp": "open",
138 "/metrics": "open",
139 "/migrate": "open",
140 "/new": "open",
141 "/notes": "open",
142 "/now": "open",
143 "/oauth": "open",
144 "/og": "open",
145 "/openapi.json": "open",
146 "/opensearch.xml": "open",
147 "/organizations": "open",
148 "/origins": "open",
149 "/packs": "mixed",
150 "/pinned": "admin",
151 "/pipeline": "open",
152 "/plugins": "open",
153 "/pricing": "open",
154 "/privacy": "open",
155 "/recover": "open",
156 "/releases": "admin",
157 "/releases.json": "open",
158 "/repos": "open",
159 "/resilience": "admin",
160 "/review": "open",
161 "/robots.txt": "open",
162 "/runs": "open",
163 "/search": "open",
164 "/settings": "mixed",
165 "/sign-in": "open",
166 "/signin": "open",
167 "/signup": "open",
168 "/sitemap.xml": "open",
169 "/sw.js": "open",
170 "/system": "admin",
171 "/talk": "open",
172 "/tell": "open",
173 "/theme": "open",
174 "/verify": "open",
175 "/welcome": "open",
176 };
177
178 /** Routes marked one by one: every route under a `mixed` head, and nothing else. */
179 export const ROUTES: Readonly<Record<string, Mark>> = {
180 "/boards": "open",
181 "/boards/data": "admin",
182 "/boards/new": "open",
183 "/boards/holds": "open",
184 "/boards/pin": "open",
185 "/boards/pin-panel": "open",
186 "/boards/unpin-panel": "open",
187 "/boards/move-pin": "open",
188 "/boards/:slug": "open",
189 "/boards/:slug/live": "open",
190 "/boards/:slug/:op": "open",
191 "/admin/flows": "open",
192 "/admin/agent-start": "admin",
193 "/admin/billing-links": "admin",
194 "/admin/data": "admin",
195 "/admin/invites": "admin",
196 "/admin/invites/:id/revoke": "admin",
197 "/admin/limits": "admin",
198 "/admin/plans": "admin",
199 "/admin/quotas": "admin",
200 "/admin/signup": "admin",
201 "/flow/keeper/answer": "admin",
202 "/flow": "open",
203 "/flow/valve": "open",
204 "/flow/backup/retry": "admin",
205 "/flow/suggestion": "open",
206 "/forges": "admin",
207 "/forges/refused": "admin",
208 "/forges/name/:name": "open",
209 "/forges/:name/claim-link": "admin",
210 "/forges/:name/revoke-link": "admin",
211 "/forges/:name/undo-link": "admin",
212 "/forges/:name/stop": "admin",
213 "/forges/:name/own-code": "admin",
214 "/forges/:name/remove": "admin",
215 "/forges/:name/ready": "open",
216 "/forges/:name/waiting": "open",
217 "/forges/new": "open",
218 "/forges/start": "open",
219 "/live/apps": "open",
220 "/live/on-you": "open",
221 "/live/client-apps": "open",
222 "/live/feed": "open",
223 "/live/milestones": "open",
224 "/live/moving": "open",
225 "/live/chips": "open",
226 "/live/home/kpis": "admin",
227 "/live/home/waiting": "open",
228 "/live/home/panels": "admin",
229 "/live/home/asked": "open",
230 "/live/home/feed": "open",
231 "/packs": "open",
232 "/packs/mirror": "admin",
233 "/packs/new": "admin",
234 "/packs/:name/edit": "admin",
235 "/packs/:name/repos": "admin",
236 "/packs/:name": "open",
237 "/settings/trusted": "admin",
238 "/settings/trusted/stop": "admin",
239 "/settings/trusted/revert": "admin",
240 "/settings/trusted/:id/remove": "admin",
241 "/settings/telemetry": "admin",
242 "/settings": "open",
243 "/settings/notify": "open",
244 "/settings/dotfiles": "open",
245 "/settings/edge/link": "open",
246 "/settings/edge/unlink": "open",
247 "/settings/parent/link": "open",
248 "/settings/parent/unlink": "open",
249 "/settings/email/verify": "open",
250 "/settings/password": "open",
251 "/settings/everyday": "open",
252 "/settings/everyday/options": "admin",
253 "/settings/everyday/switch": "open",
254 "/settings/everyday/keep": "open",
255 "/settings/shells": "open",
256 "/settings/shells/stop-idle": "open",
257 "/settings/shells/:id/stop": "open",
258 "/settings/tokens": "open",
259 "/settings/tokens/:id/delete": "open",
260 "/settings/sessions/:id/delete": "open",
261 "/settings/confirms": "open",
262 "/settings/confirms/:id": "open",
263 "/settings/passkeys": "open",
264 "/settings/passkeys/options": "open",
265 "/settings/passkeys/:id/delete": "open",
266 "/settings/subscriptions": "admin",
267 "/settings/crew/key": "admin",
268 "/settings/crew/budgets": "admin",
269 "/settings/subscriptions/:id/delete": "admin",
270 "/settings/subscriptions/:id/wake": "admin",
271 "/settings/subscriptions/:id/headroom": "admin",
272 "/settings/subscriptions/:id/budget": "admin",
273 "/settings/secrets": "admin",
274 "/settings/secrets/delete": "admin",
275 "/settings/devices": "admin",
276 "/settings/devices/delete": "admin",
277 "/settings/devices/grant": "admin",
278 "/settings/devices/revoke": "admin",
279 "/settings/devices/cap": "admin",
280 "/settings/cloud": "admin",
281 "/settings/cloud/verify": "admin",
282 "/settings/cloud/write": "admin",
283 "/settings/cloud/audit": "admin",
284 "/settings/cloud/rotate": "admin",
285 "/settings/cloud/rename": "admin",
286 "/settings/cloud/remove": "admin",
287 "/settings/access": "admin",
288 "/settings/resilience": "admin",
289 "/settings/capacity": "admin",
290 "/settings/economics": "admin",
291 "/settings/billing": "admin",
292 "/settings/billing/grant": "admin",
293 "/settings/billing/links": "admin",
294 "/settings/billing/portal": "admin",
295 "/settings/billing/stripe/connect": "admin",
296 "/settings/billing/stripe/secret": "admin",
297 "/settings/billing/stripe/disconnect": "admin",
298 "/settings/billing/orders/retry": "admin",
299 "/settings/billing/codes": "admin",
300 "/settings/billing/codes/revoke": "admin",
301 "/settings/flags": "admin",
302 "/settings/captain": "admin",
303 "/settings/packs": "admin",
304 "/settings/features": "admin",
305 "/settings/features/:slug": "admin",
306 "/settings/features/:slug/run/:tool": "admin",
307 "/settings/features/:slug/absorb": "admin",
308 "/settings/features/:slug/unabsorb": "admin",
309 "/settings/features/:slug/accept": "admin",
310 "/settings/app-templates": "admin",
311 "/settings/usage": "admin",
312 "/settings/spend": "admin",
313 "/settings/traffic": "admin",
314 "/settings/traffic/returning": "admin",
315 "/settings/traffic/sources": "admin",
316 "/settings/traffic/scrapes": "admin",
317 "/settings/traffic/scrapes/export": "admin",
318 "/settings/traffic/scrapes/capture": "admin",
319 "/settings/traffic/scrapes/stop": "admin",
320 "/settings/traffic/scrapes/networks": "admin",
321 "/settings/security": "admin",
322 "/settings/security/controls": "admin",
323 "/settings/security/shield": "admin",
324 "/settings/backups": "admin",
325 "/settings/backups/live": "admin",
326 "/settings/backups/now": "admin",
327 "/settings/backups/drill": "admin",
328 "/settings/backups/destination": "admin",
329 "/settings/backups/test": "admin",
330 "/settings/backups/clear": "admin",
331 "/settings/backups/mirror": "admin",
332 "/settings/backups/mirror/clear": "admin",
333 "/settings/backups/mirror/now": "admin",
334 "/settings/accounts": "admin",
335 "/settings/accounts/:username/allow": "admin",
336 "/settings/accounts/:username/decline": "admin",
337 "/settings/people": "admin",
338 "/settings/people/:username/role": "admin",
339 "/settings/model": "open",
340 "/settings/accounts/:username/model": "admin",
341 "/settings/accounts/:username/ceiling": "admin",
342 "/settings/accounts/:username": "admin",
343 "/settings/accounts/:username/tokens/:id/delete": "admin",
344 "/settings/accounts/:username/sessions/:id/delete": "admin",
345 "/settings/accounts/:username/suspend": "admin",
346 "/settings/accounts/:username/unsuspend": "admin",
347 "/settings/accounts/:username/remove": "admin",
348 "/settings/accounts/:username/retire": "admin",
349 "/settings/accounts/:username/verify": "admin",
350 "/settings/accounts/:username/reset": "admin",
351 "/settings/accounts/:username/end-sessions": "admin",
352 "/settings/accounts/:username/way-back": "admin",
353 "/settings/crew": "admin",
354 "/settings/crew/knobs": "admin",
355 "/settings/teams": "admin",
356 "/settings/teams/stream": "admin",
357 "/settings/teams/:slug": "admin",
358 "/settings/crew/:username": "admin",
359 "/settings/crew/:username/delete": "admin",
360 "/settings/crew/model-review/:id/take": "admin",
361 "/settings/crew/model-review/:id/decline": "admin",
362 "/settings/origins/callback": "open",
363 "/settings/origins": "open",
364 "/settings/origins/token": "open",
365 "/settings/origins/oauth": "open",
366 "/settings/origins/apps": "open",
367 "/settings/origins/trusted-hosts": "admin",
368 "/settings/origins/apps/:id/delete": "open",
369 "/settings/origins/flows/:id": "open",
370 "/settings/origins/flows/:id/poll": "open",
371 "/settings/origins/flows/:id/stream": "open",
372 "/settings/origins/:id/repos": "open",
373 "/settings/origins/:id/archived": "open",
374 "/settings/origins/:id/mirror": "open",
375 "/settings/origins/:id/mirror/:job": "open",
376 "/settings/origins/:id/sync": "open",
377 "/settings/origins/:id/check": "open",
378 "/settings/origins/:id/delete": "open",
379 "/settings/origins/:id/token": "open",
380 "/settings/keys": "open",
381 "/settings/keys/:id/delete": "open",
382 };
383
384 /** The head a pattern or a path is marked by. */
385 export function headOf(path: string): string {
386 const parts = path.split("/");
387 return path.startsWith("/api/") ? parts.slice(0, 4).join("/") : `/${parts[1] ?? ""}`;
388 }
389
390 /** A pattern's mark, or undefined when it has none: a route that has not chosen. */
391 export function markOf(pattern: string): Mark | undefined {
392 const own = ROUTES[pattern];
393 if (own !== undefined) return own;
394 const head = HEADS[headOf(pattern)];
395 return head === "mixed" ? undefined : head;
396 }
397
398 let listed: Router<Mark> | undefined;
399 /** ROUTES as a matcher, literal segments before parameters so `/packs/new` is not read as `/packs/:name`. */
400 function listedRoutes(): Router<Mark> {
401 if (listed !== undefined) return listed;
402 const params = (p: string) => p.split("/").filter((s) => s.startsWith(":") || s.startsWith("*")).length;
403 const matcher = new Router<Mark>();
404 for (const pattern of Object.keys(ROUTES).sort((a, b) => params(a) - params(b)))
405 matcher.add("*", pattern, ROUTES[pattern] as Mark);
406 return (listed = matcher);
407 }
408
409 /** Whether a request path falls on a route marked `admin`; unlisted under a `mixed` head counts as `admin`. */
410 export function isAdminRoute(path: string): boolean {
411 const own = listedRoutes().match("*", path);
412 if (own !== undefined) return own.handler === "admin";
413 const head = HEADS[headOf(path)];
414 return head === "admin" || head === "mixed";
415 }
src/http/server.ts +1 −1
@@ -3020,7 +3020,7 @@ async function answerGet(
30203020 if (viaRefusal !== undefined) return viaRefusal;
30213021
30223022 // Admin settings and releases are an admin's; a member is told so.
3023 const adminRefusal = adminGate(app, request, url, auth);
3023 const adminRefusal = adminGate(app, request, url, auth, matched.pattern);
30243024 if (adminRefusal !== undefined) return adminRefusal;
30253025
30263026 // A transferred repository's old path answers 308 to its new one (#569).
test/access-language.test.ts +2 −2
@@ -70,7 +70,7 @@ test("one logged request: the filter and a table row, escaped, no card", async (
7070 expect(page).toContain("&lt;b&gt;probe");
7171 });
7272
73 test("a non-admin still gets 404", async () => {
73 test("a non-admin is refused with 403", async () => {
7474 const res = await fetch(servers.url + "/settings/access", { headers: { cookie: cookies.bob! } });
75 expect(res.status).toBe(404);
75 expect(res.status).toBe(403);
7676 });
test/account-access.test.ts +4 −4
@@ -86,20 +86,20 @@ describe("an admin resets a person's access", () => {
8686 await rm(dataDir, { recursive: true, force: true });
8787 });
8888
89 test("a profile offers Manage account to an admin only, and the page answers 404 to anyone else", async () => {
89 test("a profile offers Manage account to an admin only, and the page refuses anyone else", async () => {
9090 const asAdmin = await (await get("/bob", owner)).text();
9191 expect(asAdmin).toContain("/settings/accounts/bob");
9292 expect(asAdmin).toContain("Manage account");
9393 expect(await (await get("/bob", bobCookie)).text()).not.toContain("Manage account");
9494 expect(await (await get("/bob")).text()).not.toContain("Manage account");
95 expect((await get("/settings/accounts/bob", bobCookie)).status).toBe(404);
95 expect((await get("/settings/accounts/bob", bobCookie)).status).toBe(403);
9696 const page = await get("/settings/accounts/bob", owner);
9797 expect(page.status).toBe(200);
9898 const text = await page.text();
9999 for (const word of ["Role", "[email protected]", "Verified", "a password is set", "Owns", "Signed in now", "Reset access", "End all sessions", "Model budget"])
100100 expect(text).toContain(word);
101101 expect(text).not.toContain("bob-old-passphrase");
102 expect((await post("/settings/accounts/bob/reset", bobCookie)).status).toBe(404);
102 expect((await post("/settings/accounts/bob/reset", bobCookie)).status).toBe(403);
103103 });
104104
105105 test("the accounts list searches by name", async () => {
@@ -217,7 +217,7 @@ describe("an admin resets a person's access", () => {
217217 expect(ended.sessionsEnded).toBe(0);
218218 });
219219
220 test("only the forge's owner manages another admin; a member is told 404", async () => {
220 test("only the forge's owner manages another admin; a member is refused with 403", async () => {
221221 const olive = createToken(app.db, getUserByName(app.db, "olive")!.id, "t", "admin").token;
222222 const refused = await api("/api/v1/admin/accounts/olive/reset", adminTwo);
223223 expect(refused.status).toBe(422);
test/accounts-language.test.ts +2 −2
@@ -61,7 +61,7 @@ test("the accounts list keeps its confirm links", async () => {
6161 expect(m).toContain("/-/confirm?to=%2Fsettings%2Faccounts%2Fbobby%2Fsuspend");
6262 });
6363
64 test("a non-admin gets the one 404", async () => {
64 test("a non-admin gets the one 403", async () => {
6565 const r = await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: plain } });
66 expect(r.status).toBe(404);
66 expect(r.status).toBe(403);
6767 });
test/accounts-retire.test.ts +2 −2
@@ -125,8 +125,8 @@ describe("accounts: the confirm page for removing an organization too", () => {
125125
126126 test("a non-admin and a cross-origin post are refused, changing nothing", async () => {
127127 await seed("rc");
128 expect((await fetch(`${base}/settings/accounts/rc/retire`, { headers: other })).status).toBe(404);
129 expect((await post("rc", other)).status).toBe(404);
128 expect((await fetch(`${base}/settings/accounts/rc/retire`, { headers: other })).status).toBe(403);
129 expect((await post("rc", other)).status).toBe(403);
130130 expect((await post("rc", { ...admin, origin: "https://evil.example" })).status).toBe(403);
131131 expect(getUserByName(app.db, "rc")?.removedAt).toBeNull();
132132 expect(existsSync(repoDir(app.reposRoot, "rcco", "ledger"))).toBe(true);
test/accounts-rows.test.ts +2 −2
@@ -107,11 +107,11 @@ test("the suspend confirm, the removal refusal, the retire page and the removed
107107 expect(main).toContain("<h2>Removed</h2>");
108108 });
109109
110 test("a non-admin still gets the 404, and the page costs a handful of statements", async () => {
110 test("a non-admin is refused with 403, and the page costs a handful of statements", async () => {
111111 const mallory = await createUser(app.db, { origin: "seed", trusted: true, username: "mallory", email: "[email protected]", password: "correct-horse-battery" });
112112 if (!mallory.ok) throw new Error(mallory.error);
113113 const theirs = `forge_session=${createSession(app.db, getUserByName(app.db, "mallory")!.id).token}`;
114 expect((await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: theirs } })).status).toBe(404);
114 expect((await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: theirs } })).status).toBe(403);
115115 await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } });
116116 expectWork(await requestStatements(app.db, `${servers.url}/settings/accounts`, { headers: { cookie } }), { max: 25 });
117117 });
test/accounts.test.ts +1 −1
@@ -175,7 +175,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
175175 const page = await fetch(`${base}/settings/accounts`, {
176176 headers: { authorization: `Bearer ${token}` },
177177 });
178 expect(page.status).toBe(404);
178 expect(page.status).toBe(403);
179179 });
180180
181181 test("the owner's list shows each account, how it came, and what it holds", async () => {
test/admin-gate.test.ts +3 −3
@@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts";
12 import { SIGN_IN_AS_ADMIN, setMember } from "../src/core/roles.ts";
1313 import { createSession } from "../src/core/sessions.ts";
1414 import { createToken } from "../src/core/tokens.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
@@ -118,7 +118,7 @@ describe("the admin gate", () => {
118118 const res = await api("GET", "/api/v1/admin/settings", u);
119119 expect(res.status).toBe(403);
120120 expect(res.headers.get("content-type")).toContain("application/json");
121 expect(await res.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN });
121 expect(await res.json()).toMatchObject({ error: "not_admin", message: SIGN_IN_AS_ADMIN });
122122 }
123123 });
124124
@@ -130,7 +130,7 @@ describe("the admin gate", () => {
130130 expect(res.headers.get("content-type")).toContain("text/html");
131131 const body = await res.text();
132132 expect(body).toContain("<h1>You can't do that here</h1>");
133 expect(body).toContain(NOT_AN_ADMIN.replace("'", "&#39;"));
133 expect(body).toContain(SIGN_IN_AS_ADMIN.replace("'", "&#39;"));
134134 }
135135 }
136136 });
test/admin_refused.test.ts +3 −3
@@ -11,7 +11,7 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { createRepo } from "../src/actions/repos.ts";
1313 import { mayRead, mayRollBack } from "../src/release/gate.ts";
14 import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts";
14 import { SIGN_IN_AS_ADMIN, setMember } from "../src/core/roles.ts";
1515 import { createSession } from "../src/core/sessions.ts";
1616 import { createToken } from "../src/core/tokens.ts";
1717 import { createUser, type User } from "../src/core/users.ts";
@@ -86,7 +86,7 @@ describe("a member is refused admin settings and releases", () => {
8686 for (const [method, path, body] of apiActs) {
8787 const refused = await call(method, path, carol, body);
8888 expect(refused.status, `${method} ${path}`).toBe(403);
89 expect(await refused.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN });
89 expect(await refused.json()).toMatchObject({ error: "not_admin", message: SIGN_IN_AS_ADMIN });
9090 }
9191 for (const path of ["/api/v1/admin/capacity", "/api/v1/admin/limits", "/api/v1/admin/people"])
9292 expect((await call("GET", path, alice)).status, path).toBe(200);
@@ -104,7 +104,7 @@ describe("a member is refused admin settings and releases", () => {
104104 for (const [method, path] of pages) {
105105 const refused = await visit(method, path, carol);
106106 expect(refused.status, `${method} ${path}`).toBe(403);
107 expect(await refused.text()).toContain(NOT_AN_ADMIN.replace("'", "&#39;"));
107 expect(await refused.text()).toContain(SIGN_IN_AS_ADMIN.replace("'", "&#39;"));
108108 }
109109 expect((await visit("GET", "/admin/limits", alice)).status).toBe(200);
110110 expect((await visit("GET", "/releases", alice)).status).toBe(200);
test/agent-language.test.ts +2 −2
@@ -69,6 +69,6 @@ test("an agent's page: one h1, plain sections, both forms, no card", async () =>
6969 expect(page).toContain("to=%2Fsettings%2Fcrew%2Fscoutly%2Fdelete");
7070 });
7171
72 test("a non-admin still gets 404", async () => {
73 expect((await fetchPage(member)).status).toBe(404);
72 test("a non-admin is refused with 403", async () => {
73 expect((await fetchPage(member)).status).toBe(403);
7474 });
test/alarm-backup.test.ts +1 −1
@@ -100,7 +100,7 @@ describe("/flow backup card", () => {
100100 const res = await retry(await login("alice"));
101101 expect(res.status).toBe(303);
102102 expect(res.headers.get("location")).toBe("/flow");
103 expect((await retry(await login("bob"))).status).toBe(404);
103 expect((await retry(await login("bob"))).status).toBe(403);
104104 expect((await retry(await login("alice"), "https://evil.example")).status).toBeGreaterThanOrEqual(400);
105105 });
106106 });
test/backups-live.test.ts +1 −1
@@ -159,7 +159,7 @@ test("a failed snapshot ends in backup.finished {ok:false}, and the live part sh
159159 test("the live part and the stream are an admin's alone", async () => {
160160 const bob = await login("bob", "another-good-passphrase");
161161 const part = await fetch(`${servers.url}/settings/backups/live`, { headers: { cookie: bob } });
162 expect(part.status).toBe(404);
162 expect(part.status).toBe(403);
163163 expect((await fetch(`${servers.url}/settings/backups/live`)).status).toBeGreaterThanOrEqual(300);
164164
165165 const frames = async (cookie: string, mustArrive: boolean): Promise<string> => {
test/billing.test.ts +3 −3
@@ -139,7 +139,7 @@ describe("billing seam", () => {
139139 expect(text).toContain("Choose the plan each forge is on.");
140140 expect(text).toContain("Paste a Stripe secret or restricted key");
141141 expect(text).toContain('data-forge="kid"');
142 expect((await send("GET", "/settings/billing", bob)).status).toBe(404);
142 expect((await send("GET", "/settings/billing", bob)).status).toBe(403);
143143 });
144144
145145 test("the Billing page says the last Stripe event and the refused signatures, or that none came", async () => {
@@ -157,9 +157,9 @@ describe("billing seam", () => {
157157 expect(after).toContain("<dt>Refused today</dt>");
158158 });
159159
160 test("a non-admin gets 404 and no event data on the Billing page", async () => {
160 test("a non-admin gets 403 and no event data on the Billing page", async () => {
161161 const res = await send("GET", "/settings/billing", bob);
162 expect(res.status).toBe(404);
162 expect(res.status).toBe(403);
163163 const text = await res.text();
164164 expect(text).not.toContain("invoice.paid");
165165 expect(text).not.toContain("Refused today");
test/boards-guide.test.ts +2 −2
@@ -176,13 +176,13 @@ describe("/boards/data", () => {
176176 expect(body.split("Average CPU cores per day, where target is forge.").length - 1).toBe(1);
177177 expect(body).toMatch(/<div class="card-foot">\s*Average CPU cores per day/);
178178 });
179 test("a member who is not a site admin gets 404 for a sentence, and no redirect", async () => {
179 test("a member who is not a site admin gets 403 for a sentence, and no redirect", async () => {
180180 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
181181 if (!r.ok) throw new Error(r.error);
182182 const member = createToken(app.db, r.value.id, "t", "admin").token;
183183 for (const q of ["ask=memory+over+the+last+3+days", "ask=zzz"]) {
184184 const res = await fetch(`${servers.url}/boards/data?${q}`, { redirect: "manual", headers: { authorization: `Bearer ${member}` } });
185 expect(res.status).toBe(404);
185 expect(res.status).toBe(403);
186186 expect(res.headers.get("location")).toBeNull();
187187 }
188188 const anon = await fetch(`${servers.url}/boards/data?ask=memory`, { redirect: "manual" });
test/boards-model-fill.test.ts +3 −3
@@ -163,10 +163,10 @@ describe("/boards/data?ask=", () => {
163163 expect((await post("zzz qqq", { origin: "https://evil.example" })).status).toBe(403);
164164 expect(calls).toEqual([]);
165165 });
166 test("a member who is not an admin gets 404 and the model is not asked", async () => {
166 test("a member who is not an admin gets 403 and the model is not asked", async () => {
167167 useModel(stub(JSON.stringify(GOOD)));
168 expect((await get("/boards/data?ask=zzz+qqq", {}, member)).status).toBe(404);
169 expect((await fetch(servers.url + "/boards/data", { method: "POST", body: new URLSearchParams({ ask: "zzz" }), headers: { authorization: `Bearer ${member}`, origin: servers.url } })).status).toBe(404);
168 expect((await get("/boards/data?ask=zzz+qqq", {}, member)).status).toBe(403);
169 expect((await fetch(servers.url + "/boards/data", { method: "POST", body: new URLSearchParams({ ask: "zzz" }), headers: { authorization: `Bearer ${member}`, origin: servers.url } })).status).toBe(403);
170170 expect(calls).toEqual([]);
171171 });
172172 });
test/boards-page.test.ts +2 −2
@@ -160,8 +160,8 @@ describe("/boards/data", () => {
160160 expect(body).not.toContain("<script>x</script>");
161161 });
162162
163 test("a non-admin and a stranger get 404; the admin's menu links Boards", async () => {
164 expect((await get("/boards/data", "bob")).status).toBe(404);
163 test("a non-admin gets 403 and a stranger 404; the admin's menu links Boards", async () => {
164 expect((await get("/boards/data", "bob")).status).toBe(403);
165165 expect((await get("/boards/data", null)).status).toBe(404);
166166 expect(await (await get("/", "admin")).text()).toContain('href="/boards"');
167167 expect(await (await get("/", "bob")).text()).not.toContain('href="/boards"');
test/capacity-selfswap.test.ts +2 −2
@@ -124,7 +124,7 @@ describe("self-swap on /capacity and MCP capacity", () => {
124124 }
125125 });
126126
127 test("a member gets no selfSwap from the tool and a 404 from the page", async () => {
127 test("a member gets no selfSwap from the tool and a 403 from the page", async () => {
128128 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
129129 if (!bob.ok) throw new Error(bob.error);
130130 const token = createToken(app.db, bob.value.id, "t", "admin").token;
@@ -132,7 +132,7 @@ describe("self-swap on /capacity and MCP capacity", () => {
132132 expect(result.isError).toBe(true);
133133 expect(result.content[0]!.text).not.toContain("selfSwap");
134134 const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${token}` } });
135 expect(res.status).toBe(404);
135 expect(res.status).toBe(403);
136136 expect(await res.text()).not.toContain("self-swap");
137137 });
138138 });
test/capacity.test.ts +2 −2
@@ -199,7 +199,7 @@ describe("capacity: only a forge admin reads or changes it", () => {
199199 headers: { cookie: bobCookie },
200200 })
201201 ).status,
202 ).toBe(404);
202 ).toBe(403);
203203 const bobPost = await fetch(`${servers.url}/settings/capacity`, {
204204 method: "POST",
205205 redirect: "manual",
@@ -210,7 +210,7 @@ describe("capacity: only a forge admin reads or changes it", () => {
210210 },
211211 body: new URLSearchParams({ key: "checks.concurrency", value: "9" }),
212212 });
213 expect(bobPost.status).toBe(404);
213 expect(bobPost.status).toBe(403);
214214
215215 for (const token of [bobAdmin, agentRw]) {
216216 // A person who is not an admin is refused; an agent is told it is not there.
test/claim-revoke.test.ts +2 −1
@@ -179,7 +179,8 @@ describe("revoking a forge link", () => {
179179
180180 test("the routes: a member and a guest get nothing, and another origin is refused", async () => {
181181 for (const cookie of [bobCookie, ""]) {
182 for (const act of ["revoke", "undo"] as const) expect((await post("one", act, cookie)).status).toBe(404);
182 // A member is refused at the admin gate; a stranger finds nothing.
183 for (const act of ["revoke", "undo"] as const) expect((await post("one", act, cookie)).status).toBe(cookie === "" ? 404 : 403);
183184 }
184185 const crossed = await post("one", "revoke", aliceCookie, "https://evil.test");
185186 expect(crossed.headers.get("location") ?? "").not.toContain("link=revoked");
test/client-list.test.ts +2 −2
@@ -151,7 +151,7 @@ describe("the client list", () => {
151151 });
152152
153153 test("someone who is not an admin of the parent gets no list: page, API or MCP", async () => {
154 expect((await list(reader)).status).toBe(404);
154 expect((await list(reader)).status).toBe(403);
155155 const tool = (await (await mcp(bobRw)).json()) as any;
156156 expect(tool.result?.isError ?? tool.error !== undefined).toBe(true);
157157 expect(JSON.stringify(tool)).not.toContain("maria");
@@ -251,7 +251,7 @@ describe("the client list", () => {
251251 headers: { cookie, origin: "https://forge.test" },
252252 redirect: "manual",
253253 });
254 expect((await fresh(reader)).status).toBe(404);
254 expect((await fresh(reader)).status).toBe(403);
255255 const res = await fresh(admin);
256256 expect(res.status).toBe(201);
257257 expect(res.headers.get("cache-control")).toContain("no-store");
test/cloud-gate.test.ts +1 −1
@@ -119,7 +119,7 @@ describe("cloud gate", () => {
119119 expect(main).toContain("Used for nothing now");
120120 const bobCookie = await login("bob");
121121 for (const path of ["/settings/cloud", "/settings/cloud/audit"])
122 expect((await fetch(`${servers.url}${path}`, { headers: { cookie: bobCookie } })).status).toBe(404);
122 expect((await fetch(`${servers.url}${path}`, { headers: { cookie: bobCookie } })).status).toBe(403);
123123 });
124124
125125 test("a gated verify audits each request; bytes out counts the body", async () => {
test/cloud-page-shape.test.ts +2 −2
@@ -57,7 +57,7 @@ test("today's vendor calls are a headed block, no card; with none one sentence s
5757 expect(main).toContain("No vendor call today. Rows are kept 90 days.");
5858 });
5959
60 test("a signed-in non-admin gets the forge's one 404 for the vendor calls", async () => {
60 test("a signed-in non-admin gets the gate's 403 for the vendor calls", async () => {
6161 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
6262 if (!made.ok) throw new Error(made.error);
6363 const r = await fetch(`${servers.url}/signin`, {
@@ -68,6 +68,6 @@ test("a signed-in non-admin gets the forge's one 404 for the vendor calls", asyn
6868 });
6969 const bob = (r.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
7070 const res = await fetch(`${servers.url}/settings/cloud/audit`, { headers: { cookie: bob } });
71 expect(res.status).toBe(404);
71 expect(res.status).toBe(403);
7272 expect(await res.text()).not.toContain("vendor call");
7373 });
test/cloud.test.ts +1 −1
@@ -165,7 +165,7 @@ describe("cloud accounts", () => {
165165 expect(page).toContain("verify");
166166 expect(page).toContain("read-only");
167167 expect(page).not.toContain(TOKEN);
168 expect((await get(await login("bob"))).status).toBe(404);
168 expect((await get(await login("bob"))).status).toBe(403);
169169 const cross = await fetch(`${servers.url}/settings/cloud/write`, {
170170 method: "POST",
171171 redirect: "manual",
test/confirms-language.test.ts +2 −2
@@ -68,6 +68,6 @@ test("a waiting ask is a row in a .list with its same-origin Yes form", async ()
6868 expect(main(await (await get("/settings/confirms", plain)).text())).not.toContain(asked.needs_confirm);
6969 });
7070
71 test("a non-admin gets the one 404 on the admin pages", async () => {
72 expect((await get("/settings/accounts", plain)).status).toBe(404);
71 test("a non-admin gets the one 403 on the admin pages", async () => {
72 expect((await get("/settings/accounts", plain)).status).toBe(403);
7373 });
test/crew-handover.test.ts +1 −1
@@ -113,7 +113,7 @@ describe("what the crew handed on", () => {
113113
114114 test("a non-admin gets neither", async () => {
115115 const { status, text } = await crewPage("bob");
116 expect(status).toBe(404);
116 expect(status).toBe(403);
117117 expect(text).not.toContain("Handed to the planner");
118118 expect(text).not.toContain("Restore drill");
119119 expect(await terminal("bob")).not.toContain("Handed to the planner: ");
test/crew-key.test.ts +2 −2
@@ -61,8 +61,8 @@ describe("adding the crew's key", () => {
6161 expect(listSubscriptions(app.db)).toEqual([]);
6262 });
6363
64 test("a non-admin gets the not-found page", async () => {
65 expect((await post(plain, GOOD)).status).toBe(404);
64 test("a non-admin gets the 403 page", async () => {
65 expect((await post(plain, GOOD)).status).toBe(403);
6666 expect(listSubscriptions(app.db)).toEqual([]);
6767 });
6868
test/crew-language.test.ts +2 −2
@@ -80,7 +80,7 @@ test("with an agent: its link is on the page, still no card", async () => {
8080 expect(main).toContain('href="/settings/crew/bot"');
8181 });
8282
83 test("a non-admin still gets 404", async () => {
83 test("a non-admin is refused with 403", async () => {
8484 const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: other }, redirect: "manual" });
85 expect(res.status).toBe(404);
85 expect(res.status).toBe(403);
8686 });
test/crew-teams.test.ts +3 −3
@@ -79,10 +79,10 @@ describe("crew teams", () => {
7979 expect(text).not.toContain("Owns <b>looks");
8080 });
8181
82 test("a non-admin gets the 404 on read and write, and a cross-origin post is refused", async () => {
82 test("a non-admin gets 403 on read and write, and a cross-origin post is refused", async () => {
8383 const page = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: cookie(bob) } });
84 expect(page.status).toBe(404);
85 expect((await post(bob, "design", new URLSearchParams({ dial: "90" }))).status).toBe(404);
84 expect(page.status).toBe(403);
85 expect((await post(bob, "design", new URLSearchParams({ dial: "90" }))).status).toBe(403);
8686 expect(dialOf("design")).toBe(30);
8787 expect((await post(alice, "design", new URLSearchParams({ dial: "5" }), "http://evil.example")).status).toBe(403);
8888 expect(dialOf("design")).toBe(30);
test/crew-week-card.test.ts +2 −2
@@ -223,12 +223,12 @@ test("a change request by the pull's own author is not a return", () => {
223223 expect(weekCard(app.db).find((l) => l.username === "smith")!.returned).toBe(before);
224224 });
225225
226 test("a non-admin: the page is not found and has no week block, the API and MCP refuse", async () => {
226 test("a non-admin: the page is refused and has no week block, the API and MCP refuse", async () => {
227227 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "pat", email: "[email protected]", password: "a-good-passphrase" });
228228 if (!u.ok) throw new Error(String(u.error));
229229 const pc = `forge_session=${createSession(app.db, u.value.id).token}`;
230230 const page = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: pc } });
231 expect(page.status).toBe(404);
231 expect(page.status).toBe(403);
232232 expect(await page.text()).not.toContain("crew-week");
233233 const pt = createToken(app.db, u.value.id, "t", "ro").token;
234234 const api = await fetch(`${servers.url}/api/v1/crew/week`, { headers: { authorization: `Bearer ${pt}` } });
test/devices.test.ts +1 −1
@@ -182,7 +182,7 @@ test("a signed-in non-admin sees nothing and can neither declare nor remove", as
182182 if (!keep.ok) throw new Error(keep.error);
183183 const before = listDevices(app.db).length;
184184 const page = await fetch(`${servers.url}/settings/devices`, { headers: { cookie: plain } });
185 expect(page.status).toBe(404);
185 expect(page.status).toBe(403);
186186 expect(await page.text()).not.toContain("Keep");
187187 await post("/settings/devices", { name: "Mine", kind: "printer", url: printerUrl }, plain);
188188 await post("/settings/devices/delete", { id: String(keep.value.id) }, plain);
test/edge-gone.test.ts +1 −1
@@ -169,7 +169,7 @@ describe("the forge with its forwardAuth edge gone", () => {
169169 expect(mintWayBack(app, owner, "olga")).toEqual({ ok: false, error: "not_found" });
170170 const beaCookie = `forge_session=${createSession(app.db, bea.id).token}`;
171171 const refused = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: beaCookie, origin: base } });
172 expect(refused.status).toBe(404);
172 expect(refused.status).toBe(403);
173173 // From another site: refused before anything is made.
174174 const crossed = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: ownerCookie, origin: "https://elsewhere.example" } });
175175 expect(crossed.status).toBe(403);
test/feature-run-now.test.ts +2 −2
@@ -168,8 +168,8 @@ describe("the route", () => {
168168 expect(page).toContain("Run now");
169169 });
170170
171 test("a non-admin gets the 404", async () => {
172 expect((await web(cyCookie, "/settings/features/radar/run/scan", true)).status).toBe(404);
171 test("a non-admin gets the 403", async () => {
172 expect((await web(cyCookie, "/settings/features/radar/run/scan", true)).status).toBe(403);
173173 });
174174
175175 test("an admin on an asleep app is sent back with a sentence, and nothing is recorded", async () => {
test/features.test.ts +2 −2
@@ -276,8 +276,8 @@ describe("absorbing an app", () => {
276276 expect((await api("GET", cyToken, "")).status).toBe(404);
277277 // A read-scope token of the admin may look, not absorb.
278278 expect((await api("PUT", adaReadToken, "/radar")).status).toBe(403);
279 expect((await web(cyCookie, "/settings/features")).status).toBe(404);
280 expect((await web(cyCookie, "/settings/features/radar/absorb", new URLSearchParams())).status).toBe(404);
279 expect((await web(cyCookie, "/settings/features")).status).toBe(403);
280 expect((await web(cyCookie, "/settings/features/radar/absorb", new URLSearchParams())).status).toBe(403);
281281 expect(getAbsorbed(app.db, appId)).toBeUndefined();
282282 });
283283
test/forge-sleep.test.ts +3 −3
@@ -301,8 +301,8 @@ describe("a hosted forge scales to zero", () => {
301301 headers: { authorization: `Bearer ${bearer}`, "content-type": "application/x-www-form-urlencoded", ...(origin === undefined ? {} : { origin }) },
302302 body: "",
303303 });
304 expect((await post("/forges/homie/stop", carolToken, "https://forge.test")).status).toBe(404);
305 expect((await post("/forges/homie/remove", carolToken, "https://forge.test")).status).toBe(404);
304 expect((await post("/forges/homie/stop", carolToken, "https://forge.test")).status).toBe(403);
305 expect((await post("/forges/homie/remove", carolToken, "https://forge.test")).status).toBe(403);
306306 expect(existsSync(join(dir, "spawn", "homie.json"))).toBe(true);
307307 const cross = await post("/forges/homie/remove", aliceToken, "https://evil.example");
308308 expect(cross.status).toBe(403);
@@ -311,7 +311,7 @@ describe("a hosted forge scales to zero", () => {
311311 expect(confirm.status).toBe(200);
312312 expect(await confirm.text()).toContain("cannot be undone");
313313 const asCarol = await fetch(`${servers.url}/forges/homie/remove`, { headers: { authorization: `Bearer ${carolToken}` } });
314 expect(asCarol.status).toBe(404);
314 expect(asCarol.status).toBe(403);
315315 const stopped = await post("/forges/homie/stop", aliceToken, "https://forge.test");
316316 expect(stopped.status).toBe(200);
317317 });
test/gift-codes.test.ts +1 −1
@@ -277,7 +277,7 @@ describe("gift codes", () => {
277277 expect(text).toContain("Stop this code working");
278278 expect(await (await send("GET", "/settings/billing", admin)).text()).not.toContain(code);
279279 expect((await form("/settings/billing/codes", admin, { plan: "free", uses: "1" })).status).toBe(422);
280 expect((await form("/settings/billing/codes", bob, { plan: "small" })).status).toBe(404);
280 expect((await form("/settings/billing/codes", bob, { plan: "small" })).status).toBe(403);
281281
282282 const home = await send("GET", `/?code=${code}`, undefined);
283283 expect(home.status).toBe(303);
test/home-frame.test.ts +1 −1
@@ -89,7 +89,7 @@ test("each late region answers alone, to its own viewer, and to no one else", as
8989 }
9090 for (const name of ["kpis", "panels"]) {
9191 const peer = await fetch(`${f.base}/live/home/${name}`, { headers: { cookie: f.peerCookie } });
92 expect(peer.status).toBe(404);
92 expect(peer.status).toBe(403);
9393 }
9494 const peerFeed = await fetch(`${f.base}/live/home/feed`, { headers: { cookie: f.peerCookie } });
9595 expect(peerFeed.status).toBe(200);
test/knobs-page.test.ts +1 −1
@@ -89,7 +89,7 @@ test("a member who is not an admin is refused and changes nothing", async () =>
8989 headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
9090 body: new URLSearchParams({ knob: "speed", value: "6", repo: "" }),
9191 });
92 expect(res.status).toBe(404);
92 expect(res.status).toBe(403);
9393 expect(capacityValue(app.db, "crew.concurrency")).toBe("3");
9494 });
9595
test/loop-stall-by-route.test.ts +2 −2
@@ -170,13 +170,13 @@ test("the stall section lists route, ms and count, the route escaped", () => {
170170 expect(text).toContain('<td class="mono">/api/v1/telemetry/flow</td><td class="num mono">0</td><td class="num mono">0</td>');
171171 });
172172
173 test("/system shows the owner the stall section with the flow route; a member gets 404", async () => {
173 test("/system shows the owner the stall section with the flow route; a member gets 403", async () => {
174174 const page = await fetch(`${servers.url}/system`, { headers: { cookie: await login("alice") } });
175175 expect(page.status).toBe(200);
176176 const html = await page.text();
177177 expect(html).toContain('id="sys-stalls"');
178178 expect(html).toContain('<tr data-route="/api/v1/telemetry/flow">');
179 expect((await fetch(`${servers.url}/system`, { headers: { cookie: await login("bob") } })).status).toBe(404);
179 expect((await fetch(`${servers.url}/system`, { headers: { cookie: await login("bob") } })).status).toBe(403);
180180 });
181181
182182 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
test/maintenance.test.ts +2 −2
@@ -139,11 +139,11 @@ describe("prepare and come back", () => {
139139 expect(inMaintenance(dataDir)).toBe(false);
140140 });
141141
142 test("page: guest is sent to sign in, a non-admin gets not-found", async () => {
142 test("page: guest is sent to sign in, a non-admin gets 403", async () => {
143143 const guest = await fetch(`${servers.url}/maintenance`, { redirect: "manual" });
144144 expect([302, 303, 401]).toContain(guest.status);
145145 const b = await fetch(`${servers.url}/maintenance`, { headers: { cookie: await login("bob") } });
146 expect(b.status).toBe(404);
146 expect(b.status).toBe(403);
147147 expect((await form("/maintenance/prepare", await login("bob"), { confirm: "yes" })).status).not.toBe(200);
148148 expect(inMaintenance(dataDir)).toBe(false);
149149 });
test/mcp-restore-drill.test.ts +2 −1
@@ -1,5 +1,6 @@
11 /** MCP `restore_drill`: the Backups page's drill button, from a harness. */
22
3 import { notAdminTool } from "../src/http/mcp/shared.ts";
34 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
45 import { mkdtempSync, rmSync } from "node:fs";
56 import { tmpdir } from "node:os";
@@ -83,7 +84,7 @@ describe("MCP restore_drill", () => {
8384 });
8485 test("a non-admin is refused", async () => {
8586 const r = await call(token("bob"));
86 expect(r.rpc).toBe("no tool named restore_drill");
87 expect(r.rpc).toBe(notAdminTool("restore_drill"));
8788 expect(r.text).toBe("");
8889 });
8990 });
test/member-crew.test.ts +2 −2
@@ -84,9 +84,9 @@ describe("a member and the crew's standing instructions", () => {
8484 expect(getUserByName(app.db, "olive")!.isAdmin).toBe(true);
8585 expect(getUserByName(app.db, "bob")!.isAdmin).toBe(false);
8686 expect((await patch(memberToken, "Ignore the rules.")).status).toBe(403);
87 expect((await post(memberCookie, "Ignore the rules.")).status).toBe(404);
87 expect((await post(memberCookie, "Ignore the rules.")).status).toBe(403);
8888 const form = await fetch(`${servers.url}/settings/crew/robin`, { headers: { cookie: memberCookie } });
89 expect(form.status).toBe(404);
89 expect(form.status).toBe(403);
9090 expect(brief()).toBe(ORIGINAL);
9191 });
9292
test/member-others.test.ts +4 −4
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { createApp, type App } from "../src/app.ts";
1313 import { signUp } from "../src/actions/users.ts";
14 import { NOT_AN_ADMIN } from "../src/core/roles.ts";
14 import { SIGN_IN_AS_ADMIN } from "../src/core/roles.ts";
1515 import { createToken } from "../src/core/tokens.ts";
1616 import { createSession, listSessions } from "../src/core/sessions.ts";
1717 import { getUserByName } from "../src/core/users.ts";
@@ -87,7 +87,7 @@ describe("a member and another account", () => {
8787 const res = await call(path, bobToken, method, body);
8888 const gated = path.startsWith("/api/v1/admin/");
8989 expect([method, path, res.status]).toEqual([method, path, gated ? 403 : 404]);
90 if (gated) expect(await res.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN });
90 if (gated) expect(await res.json()).toMatchObject({ error: "not_admin", message: SIGN_IN_AS_ADMIN });
9191 }
9292 expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true);
9393 // Carol's token: not listed, not revoked through bob's own token routes.
@@ -122,9 +122,9 @@ describe("a member and another account", () => {
122122 [`/settings/accounts/carol/sessions/${sid}/delete`, {}],
123123 ];
124124 for (const [path, form] of forms)
125 expect([path, (await post(path, form)).status]).toEqual([path, 404]);
125 expect([path, (await post(path, form)).status]).toEqual([path, 403]);
126126 const page = await fetch(`${servers.url}/settings/accounts/carol`, { headers: { cookie } });
127 expect(page.status).toBe(404);
127 expect(page.status).toBe(403);
128128 expect((await call("/api/v1/user", laptop.token)).status).toBe(200);
129129 expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true);
130130 expect(JSON.stringify(getUserByName(app.db, "carol"))).toBe(carolBefore);
test/model-grant-pages.test.ts +3 −3
@@ -72,7 +72,7 @@ describe("a model grant, from the account's page and the API", () => {
7272 body: new URLSearchParams({ usd: "20" }),
7373 redirect: "manual",
7474 });
75 expect(refused.status).toBe(404);
75 expect(refused.status).toBe(403);
7676 expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)).toBeUndefined();
7777
7878 const admin = await signIn("alice");
@@ -118,7 +118,7 @@ describe("a model grant, from the account's page and the API", () => {
118118 expect(getModelGrant(app.db, carol.id)).toBeUndefined();
119119 });
120120
121 test("the ceiling route: signed out, a person's 404, a bad amount's flash, saved; the card shows it", async () => {
121 test("the ceiling route: signed out, a person's 403, a bad amount's flash, saved; the card shows it", async () => {
122122 const url = `${servers.url}/settings/accounts/carol/ceiling`;
123123 const send = (cookie: string | undefined, daily: string, perRun: string) =>
124124 fetch(url, {
@@ -132,7 +132,7 @@ describe("a model grant, from the account's page and the API", () => {
132132 const bob = await signIn("bob");
133133 expect((await send(undefined, "5", "1")).status).not.toBe(200);
134134 expect(getAccountCeiling(app.db, carol.id)).toBeUndefined();
135 expect((await send(bob, "5", "1")).status).toBe(404);
135 expect((await send(bob, "5", "1")).status).toBe(403);
136136 expect(getAccountCeiling(app.db, carol.id)).toBeUndefined();
137137 const bad = await send(alice, "abc", "1");
138138 expect(bad.status).toBe(303);
test/model-language.test.ts +1 −1
@@ -81,7 +81,7 @@ test("a ceiling still posts for an admin only, behind the same-origin check", as
8181 const asBob = await fetch(url, {
8282 method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.bob!, origin: servers.url },
8383 });
84 expect(asBob.status).toBe(404);
84 expect(asBob.status).toBe(403);
8585 const foreign = await fetch(url, {
8686 method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.alice!, origin: "https://evil.test" },
8787 });
test/new-forge.test.ts +2 −2
@@ -107,7 +107,7 @@ describe("+ New forge", () => {
107107 'href="/forges/new"',
108108 );
109109 expect((await get(reader, "/forges/new")).status).toBe(200);
110 expect((await get(reader, "/forges")).status).toBe(404);
110 expect((await get(reader, "/forges")).status).toBe(403);
111111 expect(
112112 (
113113 await fetch(url("/forges/kai/claim-link"), {
@@ -116,7 +116,7 @@ describe("+ New forge", () => {
116116 redirect: "manual",
117117 })
118118 ).status,
119 ).toBe(404);
119 ).toBe(403);
120120 expect(await children()).toBe(0);
121121 });
122122
test/nightly-output.test.ts +2 −2
@@ -167,8 +167,8 @@ describe("the page a nightly issue links to", () => {
167167 expect(await res.text()).toContain("No output here yet");
168168 });
169169
170 test("anyone but an admin, and any job that is not one, is not found", async () => {
171 expect((await fetch(`${servers.url}${runPath("outside-gate")}`, { headers: { cookie: await login("bob") } })).status).toBe(404);
170 test("anyone but an admin is refused, and any job that is not one is not found", async () => {
171 expect((await fetch(`${servers.url}${runPath("outside-gate")}`, { headers: { cookie: await login("bob") } })).status).toBe(403);
172172 expect((await fetch(`${servers.url}${runPath("sync-env")}`, { headers: { cookie: await login("alice") } })).status).toBe(404);
173173 const anon = await fetch(`${servers.url}${runPath("outside-gate")}`, { redirect: "manual" });
174174 expect(anon.status).not.toBe(200);
test/packs-curated.test.ts +1 −1
@@ -86,7 +86,7 @@ test("an unknown op is refused and says so, and changes nothing", async () => {
8686 test("only an admin sees Edit; a non-admin's and a cross-origin edit are refused", async () => {
8787 expect(await text("/packs/gamer", plain)).not.toContain("Edit this pack");
8888 expect(await text("/packs/gamer", admin)).toContain("Edit this pack");
89 expect((await post("/packs/gamer/repos", plain, { op: "remove", repo: "trevato/chess" })).status).toBe(404);
89 expect((await post("/packs/gamer/repos", plain, { op: "remove", repo: "trevato/chess" })).status).toBe(403);
9090 expect((await post("/packs/gamer/repos", admin, { op: "remove", repo: "trevato/chess" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400);
9191 expect(getPack(app.db, "gamer")!.repos).toContain("trevato/chess");
9292 });
test/packs-settings.test.ts +1 −1
@@ -53,7 +53,7 @@ test("nothing is marked, so /packs lists no pack even though the repos exist", a
5353 });
5454
5555 test("only an admin sees the page or saves; a cross-origin post is refused", async () => {
56 expect((await fetch(`${servers.url}/settings/packs`, { headers: { cookie: plain } })).status).toBe(404);
56 expect((await fetch(`${servers.url}/settings/packs`, { headers: { cookie: plain } })).status).toBe(403);
5757 expect((await save(plain, "packs/games")).status).not.toBe(303);
5858 expect((await save(admin, "packs/games", "https://evil.example")).status).toBeGreaterThanOrEqual(400);
5959 expect(markedRepos(app.db)).toEqual([]);
test/people-timing.test.ts +3 −3
@@ -60,7 +60,7 @@ test("GET /settings/people names its phases", async () => {
6060 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
6161 });
6262
63 test("a non-admin gets the 404 without the admin phases", async () => {
63 test("a non-admin gets the 403 without the admin phases", async () => {
6464 const b = await createUser(app.db, {
6565 origin: "seed",
6666 trusted: true,
@@ -72,7 +72,7 @@ test("a non-admin gets the 404 without the admin phases", async () => {
7272 proveAll(app);
7373 const cookie = await signIn("bob");
7474 const get = await fetch(`${servers.url}/settings/people`, { headers: { cookie } });
75 expect(get.status).toBe(404);
75 expect(get.status).toBe(403);
7676 for (const name of ["people", "render", "role"]) expect(phase(get, name)).toBeUndefined();
7777 const post = await fetch(`${servers.url}/settings/people/alice/role`, {
7878 method: "POST",
@@ -80,7 +80,7 @@ test("a non-admin gets the 404 without the admin phases", async () => {
8080 body: new URLSearchParams({ role: "guest" }),
8181 redirect: "manual",
8282 });
83 expect(post.status).toBe(404);
83 expect(post.status).toBe(403);
8484 for (const name of ["session", "role"]) expect(phase(post, name)).toBeUndefined();
8585 });
8686
test/pinned-page.test.ts +5 −5
@@ -80,8 +80,8 @@ describe("deploy-to page", () => {
8080 });
8181
8282 test("someone who is not an admin finds nothing", async () => {
83 expect((await get("/deploy-to", bobTok)).status).toBe(404);
84 expect((await post(good, bobTok)).status).toBe(404);
83 expect((await get("/deploy-to", bobTok)).status).toBe(403);
84 expect((await post(good, bobTok)).status).toBe(403);
8585 expect(getPinned(app.db, "acme")).toBeUndefined();
8686 });
8787
@@ -109,7 +109,7 @@ describe("deploy-to page", () => {
109109 });
110110
111111 describe("pinned list page", () => {
112 test("renders none, then one forge; a non-admin gets 404", async () => {
112 test("renders none, then one forge; a non-admin gets 403", async () => {
113113 const dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-list-"));
114114 const app = createApp({ dataDir, port: 0 });
115115 const servers = await serve(app);
@@ -122,7 +122,7 @@ describe("pinned list page", () => {
122122 const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token;
123123 const get = (tok?: string) => fetch(servers.url + "/pinned", { redirect: "manual", headers: tok ? { authorization: `Bearer ${tok}` } : {} });
124124 expect((await get()).status).toBe(303);
125 expect((await get(bobTok)).status).toBe(404);
125 expect((await get(bobTok)).status).toBe(403);
126126 const empty = await (await get(adminTok)).text();
127127 expect(empty).toContain("No pinned forge yet");
128128 expect(empty).toContain('href="/deploy-to"');
@@ -204,7 +204,7 @@ describe("pinned list page actions", () => {
204204 expect((await get("/-/confirm?to=/pinned/Bad_Name/update", adminTok)).status).toBe(404);
205205
206206 // Not an admin: 404 and nothing moves. Cross-origin: 403.
207 for (const a of ["update", "rollback", "check"]) expect((await act(`/pinned/acme/${a}`, bobTok, { version: V2 })).status).toBe(404);
207 for (const a of ["update", "rollback", "check"]) expect((await act(`/pinned/acme/${a}`, bobTok, { version: V2 })).status).toBe(403);
208208 expect((await act("/pinned/acme/update", adminTok, { version: V2 }, "https://evil.example")).status).toBe(403);
209209 expect(getPinned(app.db, "acme")!.version).toBe(V);
210210
test/plan-wiring.test.ts +1 −1
@@ -114,7 +114,7 @@ test("the portal setting refuses a member, an agent and a cross-origin post; not
114114 fetch(`${servers.url}/settings/billing/portal`, { method: "POST", headers: { cookie, origin, accept: "text/html" }, body: new URLSearchParams({ portal_url: PORTAL }), redirect: "manual" });
115115 const mine = `forge_session=${createSession(app.db, member.id).token}`;
116116 const theirs = `forge_session=${createSession(app.db, admin.id).token}`;
117 expect((await post(mine, servers.url)).status).toBe(404);
117 expect((await post(mine, servers.url)).status).toBe(403);
118118 expect((await post(theirs, "https://evil.test")).status).toBe(403);
119119 expect(getPortalUrl(app.db)).toBe(before);
120120 expect((await post(theirs, servers.url)).status).toBe(303);
test/print.test.ts +1 −1
@@ -332,7 +332,7 @@ test("only an admin sets the cap, and setting it appends device.cap_set", async
332332 body: new URLSearchParams({ id: String(deviceId), app: "racer", cap: "7" }),
333333 redirect: "manual",
334334 });
335 expect(res.status).toBe(404);
335 expect(res.status).toBe(403);
336336 expect(cap()).toBe(1);
337337 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'device.cap_set'").get()!.n;
338338 expect(setCap(app, owner, deviceId, "racer", 7).ok).toBe(true);
test/resilience-page.test.ts +1 −1
@@ -80,7 +80,7 @@ describe("Settings → Backups → Failure domains", () => {
8080 const bob = await fetch(`${servers.url}/settings/backups`, {
8181 headers: { cookie: await login("bob") },
8282 });
83 expect(bob.status).toBe(404);
83 expect(bob.status).toBe(403);
8484 expect(await bob.text()).not.toContain("Failure domains");
8585 const anon = await fetch(`${servers.url}/settings/backups`, { redirect: "manual" });
8686 expect(anon.status).toBe(303);
test/resilience.test.ts +2 −2
@@ -120,7 +120,7 @@ describe("resilience", () => {
120120 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
121121 };
122122
123 test("a guest is sent to sign in, a non-admin gets 404, an admin sees the rows", async () => {
123 test("a guest is sent to sign in, a non-admin gets 403, an admin sees the rows", async () => {
124124 for (const [name, admin] of [["alice", true], ["bob", false]] as const) {
125125 const made = await createUser(app.db, {
126126 origin: "seed",
@@ -139,7 +139,7 @@ describe("resilience", () => {
139139 expect(guest.status).not.toBe(200);
140140 expect(guest.status).not.toBe(404);
141141 const bob = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("bob") } });
142 expect(bob.status).toBe(404);
142 expect(bob.status).toBe(403);
143143 expect(await bob.text()).not.toContain("data-standing");
144144
145145 const res = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("alice") } });
test/roles.test.ts +2 −2
@@ -85,7 +85,7 @@ describe("roles on /settings/people", () => {
8585 expect(html).toContain("bob");
8686 expect(html).toContain("Make member");
8787 const bob = await signIn("bob");
88 expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(404);
88 expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(403);
8989 });
9090
9191 test("the account page shows the caller's own row of the table", async () => {
@@ -124,7 +124,7 @@ describe("roles on /settings/people", () => {
124124
125125 test("a non-admin, a foreign origin, an admin target and an unknown role are refused", async () => {
126126 const bob = await signIn("bob");
127 expect((await post(bob, "bob", { role: "member" })).status).toBe(404);
127 expect((await post(bob, "bob", { role: "member" })).status).toBe(403);
128128 expect(roleNow("bob")).toBe("guest");
129129 const alice = await signIn("alice");
130130 expect((await post(alice, "bob", { role: "member" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400);
test/route-marks.test.ts added +261
@@ -0,0 +1,261 @@
1 /**
2 * Every admin-only action refuses the everyday account (#11818). Both routers
3 * are walked: each route must be marked `admin` or `open` in
4 * `src/http/route_marks.ts`, so a new route has to choose. Each route marked
5 * `admin` is called with an everyday (member) account and with the admin:
6 * the first is refused with a 403 and the one sentence that says to sign in
7 * as admin, the second is let through to its handler. Each MCP tool marked
8 * `adminOnly` is called by the everyday account and refused with the same
9 * sentence, and is listed to the admin.
10 */
11
12 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13 import { mkdtemp, rm } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import { createApp, type App } from "../src/app.ts";
17 import { SIGN_IN_AS_ADMIN, setMember } from "../src/core/roles.ts";
18 import { createSession } from "../src/core/sessions.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { TOOLS } from "../src/http/mcp/tools.ts";
22 import { mintViaToken } from "../src/core/via_token.ts";
23 import { readFenced, waitingFor } from "../src/core/fence.ts";
24 import { HEADS, ROUTES, headOf, isAdminRoute, markOf } from "../src/http/route_marks.ts";
25 import { buildApiRouter, buildRouter, forgeHttp, type ForgeHttp } from "../src/http/server.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 const FILL: Record<string, string> = {
31 owner: "alice", username: "alice", name: "notes", number: "1", org: "alice",
32 id: "1", team: "core", scope: "alice", job: "1", oid: "0", env: "prod", sha: "a".repeat(40),
33 };
34 const fill = (pattern: string): string =>
35 pattern.replace(/:([a-z_]+)/g, (_, k: string) => FILL[k] ?? "x").replace(/\*[a-z_]+/, "x");
36
37 /** Admin pages with nothing in the path to fill that still answer 4xx on a bare forge, each with what it waits for. */
38 const ADMIN_GET_WANTS: Record<string, string> = {
39 "GET /settings/traffic/scrapes/export": "a running capture and its ?group, else not found",
40 "GET /settings/backups/live": "a snapshot store, which a bare forge has not started",
41 "GET /api/v1/admin/profile": "a query naming what to profile, else 422",
42 };
43
44 const routes = () => [
45 ...buildRouter().routes().map((r) => ({ ...r, api: false })),
46 ...buildApiRouter().routes().map((r) => ({ ...r, api: true })),
47 ];
48
49 describe("the route marks", () => {
50 test("every registered route is marked admin or open", () => {
51 const unmarked = routes().filter((r) => markOf(r.pattern) === undefined).map((r) => `${r.method} ${r.pattern}`);
52 expect(unmarked).toEqual([]);
53 });
54
55 test("a route marked neither fails: an unknown head, or a new route under a mixed head", () => {
56 expect(markOf("/brand-new-page")).toBeUndefined();
57 expect(markOf("/api/v1/brand-new")).toBeUndefined();
58 expect(HEADS["/settings"]).toBe("mixed");
59 expect(markOf("/settings/brand-new")).toBeUndefined();
60 });
61
62 test("the marks name only registered routes and heads, so neither list goes stale", () => {
63 const patterns = new Set(routes().map((r) => r.pattern));
64 expect(Object.keys(ROUTES).filter((p) => !patterns.has(p))).toEqual([]);
65 const heads = new Set([...patterns].map((p) => (p.startsWith("/api/") ? p.split("/").slice(0, 4).join("/") : `/${p.split("/")[1]}`)));
66 expect(Object.keys(HEADS).filter((h) => !heads.has(h))).toEqual([]);
67 });
68
69 test("each route whose admin side is not called is a registered admin route", () => {
70 const admin = new Set(routes().filter((r) => markOf(r.pattern) === "admin").map((r) => `${r.method} ${r.pattern}`));
71 expect(Object.keys(ADMIN_GET_WANTS).filter((k) => !admin.has(k))).toEqual([]);
72 });
73
74 test("a head holding both admin and open routes is mixed, so each of its routes chooses", () => {
75 const exceptions = Object.entries(ROUTES).filter(([p, m]) => {
76 const head = HEADS[headOf(p)];
77 return head !== "mixed" && head !== m;
78 });
79 expect(exceptions.map(([p]) => p)).toEqual([]);
80 });
81
82 test("an unlisted path under a mixed head is admin: a forgotten mark fails closed", () => {
83 expect(isAdminRoute("/settings/brand-new")).toBe(true);
84 expect(isAdminRoute("/admin/brand-new")).toBe(true);
85 expect(isAdminRoute("/boards/a/b/c/d")).toBe(true);
86 });
87
88 test("a path falls on its pattern's mark, literal before parameter", () => {
89 expect(isAdminRoute("/packs/new")).toBe(true);
90 expect(isAdminRoute("/packs/someone")).toBe(false);
91 expect(isAdminRoute("/settings/billing")).toBe(true);
92 expect(isAdminRoute("/settings/tokens")).toBe(false);
93 expect(isAdminRoute("/admin/flows")).toBe(false);
94 expect(isAdminRoute("/api/v1/admin/capacity")).toBe(true);
95 expect(isAdminRoute("/alice/notes")).toBe(false);
96 });
97
98 test("the refusal is one sentence that says to sign in as admin", () => {
99 expect(SIGN_IN_AS_ADMIN).toContain("sign in as admin");
100 expect(SIGN_IN_AS_ADMIN.match(/[.!?]/g)?.length).toBe(1);
101 expect(SIGN_IN_AS_ADMIN.endsWith(".")).toBe(true);
102 });
103 });
104
105 describe("every admin-only action refuses the everyday account", () => {
106 let dataDir = "";
107 let app: App;
108 let http: ForgeHttp;
109 let alice: User; // admin
110 let carol: User; // everyday: a member, no `administer`
111 const cells = process.env["FORGE_CELLS"];
112
113 beforeAll(async () => {
114 process.env["FORGE_CELLS"] = "off";
115 dataDir = await mkdtemp(join(tmpdir(), "forge-route-marks-"));
116 app = createApp({ dataDir, port: 0 });
117 const mk = async (username: string, isAdmin: boolean) => {
118 const r = await createUser(app.db, {
119 origin: "seed", trusted: true, isAdmin, username, email: `${username}@forge.local`, password: "a-good-passphrase",
120 });
121 if (!r.ok) throw new Error(r.error);
122 return r.value;
123 };
124 alice = await mk("alice", true);
125 carol = await mk("carol", false);
126 setMember(app.db, carol.id, true, alice.id, new Date(0));
127 http = forgeHttp(app);
128 }, 60_000);
129
130 afterAll(async () => {
131 app.close();
132 await rm(dataDir, { recursive: true, force: true });
133 if (cells === undefined) delete process.env["FORGE_CELLS"];
134 else process.env["FORGE_CELLS"] = cells;
135 });
136
137 const creds = new Map<string, { bearer: string; cookie: string }>();
138 const credsOf = (u: User) => {
139 const known = creds.get(u.id);
140 if (known !== undefined) return known;
141 const made = {
142 bearer: `Bearer ${createToken(app.db, u.id, "t", "admin").token}`,
143 cookie: `forge_session=${createSession(app.db, u.id).token}`,
144 };
145 creds.set(u.id, made);
146 return made;
147 };
148 /** A refusal's status and words; a stream the gate let through is not read, only its status. */
149 const send = async (u: User, method: string, pattern: string, api: boolean) => {
150 const { bearer, cookie } = credsOf(u);
151 const headers: Record<string, string> = api
152 ? { authorization: bearer, "content-type": "application/json" }
153 : { cookie, accept: "text/html", "content-type": "application/json" };
154 if (method !== "GET") headers["origin"] = "http://forge.test";
155 const res = await http.handle(
156 new Request(`http://forge.test${fill(pattern)}`, { method, headers, ...(method === "GET" ? {} : { body: "{}" }) }),
157 );
158 if (res.status !== 403) {
159 await res.body?.cancel();
160 return { status: res.status, text: "" };
161 }
162 return { status: res.status, text: await res.text() };
163 };
164 const refusedKindly = (r: { status: number; text: string }, api: boolean): boolean =>
165 r.status === 403 &&
166 (api ? JSON.parse(r.text).message === SIGN_IN_AS_ADMIN : r.text.includes(SIGN_IN_AS_ADMIN.replace("'", "&#39;")));
167
168 test("each admin route refuses the everyday account with the sentence, and lets the admin through", async () => {
169 const admin = routes().filter((r) => markOf(r.pattern) === "admin");
170 expect(admin.length).toBeGreaterThan(150);
171 const wrong: string[] = [];
172 for (const r of admin) {
173 const everyday = await send(carol, r.method, r.pattern, r.api);
174 if (!refusedKindly(everyday, r.api)) wrong.push(`${r.method} ${r.pattern}: everyday got ${everyday.status}`);
175 const own = await send(alice, r.method, r.pattern, r.api);
176 if (refusedKindly(own, r.api) || own.status >= 500) wrong.push(`${r.method} ${r.pattern}: admin got ${own.status}`);
177 // A page with nothing to fill in answers the admin outright.
178 if (r.method === "GET" && !r.pattern.includes(":") && own.status >= 400 && !(`GET ${r.pattern}` in ADMIN_GET_WANTS))
179 wrong.push(`${r.method} ${r.pattern}: admin got ${own.status}`);
180 }
181 expect(wrong).toEqual([]);
182 }, 120_000);
183
184 const mcp = async (u: User, method: string, params: Record<string, unknown>, token?: string) => {
185 const res = await http.handle(
186 new Request("http://forge.test/mcp", {
187 method: "POST",
188 headers: {
189 "content-type": "application/json",
190 accept: "application/json, text/event-stream",
191 authorization: `Bearer ${token ?? createToken(app.db, u.id, "t", "admin").token}`,
192 },
193 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
194 }),
195 );
196 return (await res.json()) as {
197 result?: { tools?: { name: string }[]; isError?: boolean; content?: { text: string }[] };
198 error?: { message: string };
199 };
200 };
201
202 test("each admin MCP tool refuses the everyday account with the sentence, and is listed to the admin", async () => {
203 const adminTools = TOOLS.filter((t) => t.adminOnly === true);
204 expect(adminTools.length).toBeGreaterThan(5);
205 const listed = async (u: User) => {
206 const names = new Set<string>();
207 let cursor: string | undefined;
208 do {
209 const page = (await mcp(u, "tools/list", cursor === undefined ? {} : { cursor })) as {
210 result?: { tools?: { name: string }[]; nextCursor?: string };
211 };
212 for (const t of page.result?.tools ?? []) names.add(t.name);
213 cursor = page.result?.nextCursor;
214 } while (cursor !== undefined);
215 return names;
216 };
217 const forAdmin = await listed(alice);
218 const forEveryday = await listed(carol);
219 const wrong: string[] = [];
220 for (const t of adminTools) {
221 const called = await mcp(carol, "tools/call", { name: t.name, arguments: {} });
222 if (!called.error?.message.endsWith(SIGN_IN_AS_ADMIN)) wrong.push(`${t.name}: everyday got ${JSON.stringify(called)}`);
223 if (forEveryday.has(t.name)) wrong.push(`${t.name}: listed to the everyday account`);
224 if (!forAdmin.has(t.name)) wrong.push(`${t.name}: not listed to the admin`);
225 }
226 expect(wrong).toEqual([]);
227 });
228
229 test("the gate marks by the route the router matched: a git path under an owner named like a mixed head is open", async () => {
230 const res = await send(carol, "GET", "/settings/demo.git/info/refs", false);
231 expect(res.status).not.toBe(403);
232 expect((await send(carol, "GET", "/settings/no-such-page", false)).status).toBe(404);
233 });
234
235 test("the admin calls each read-only admin MCP tool and it answers; the write ones stay listed only", async () => {
236 const reads = TOOLS.filter((t) => t.adminOnly === true && !t.writes);
237 expect(reads.length).toBeGreaterThan(3);
238 const wrong: string[] = [];
239 for (const t of reads) {
240 const called = await mcp(alice, "tools/call", { name: t.name, arguments: {} });
241 if (called.error !== undefined || called.result?.isError === true)
242 wrong.push(`${t.name}: ${JSON.stringify(called).slice(0, 200)}`);
243 }
244 expect(wrong).toEqual([]);
245 });
246
247 test("an everyday account's agent calling an admin MCP tool is refused before anything is held or asked", async () => {
248 const via = "conversation:routemarks";
249 const minted = mintViaToken(app.db, carol.id, via, "", new Date(Date.now() + 10 * 60_000).toISOString());
250 if (!minted.ok) throw new Error(minted.error);
251 // The agent has read fenced text, so a write it was not asked for would be held, were the call let that far.
252 readFenced(app.db, via);
253 const wrong: string[] = [];
254 for (const t of TOOLS.filter((t) => t.adminOnly === true)) {
255 const called = await mcp(carol, "tools/call", { name: t.name, arguments: {} }, minted.value.token);
256 if (!called.error?.message.endsWith(SIGN_IN_AS_ADMIN)) wrong.push(`${t.name}: ${JSON.stringify(called).slice(0, 200)}`);
257 }
258 expect(wrong).toEqual([]);
259 expect(waitingFor(app.db, carol.id)).toEqual([]);
260 });
261 });
test/scrape-evidence.test.ts +2 −2
@@ -137,7 +137,7 @@ test("the page, capture, import and export are an admin's and need a same-origin
137137 const cookie = `forge_session=${createSession(app.db, admin.id).token}`;
138138 const other = `forge_session=${createSession(app.db, bob.id).token}`;
139139 const url = `${servers.url}/settings/traffic/scrapes`;
140 expect((await fetch(url, { headers: { cookie: other } })).status).toBe(404);
140 expect((await fetch(url, { headers: { cookie: other } })).status).toBe(403);
141141 const page = await fetch(url, { headers: { cookie } });
142142 expect(page.status).toBe(200);
143143 const text = await page.text();
@@ -167,7 +167,7 @@ test("the page, capture, import and export are an admin's and need a same-origin
167167 const reportText = await report.text();
168168 expect(reportText).toContain("[email protected]");
169169 expect(reportText).toContain("Limits:");
170 expect((await fetch(`${url}/export?group=${id}`, { headers: { cookie: other } })).status).toBe(404);
170 expect((await fetch(`${url}/export?group=${id}`, { headers: { cookie: other } })).status).toBe(403);
171171
172172 expect((await post("stop", "")).status).toBe(303);
173173 expect(captureStore.current(Date.now())).toBeNull();
test/secret-store.test.ts +1 −1
@@ -317,7 +317,7 @@ describe("forge-level secrets", () => {
317317 "Signup has no human check. Set FORGE_TURNSTILE_SECRET and FORGE_TURNSTILE_SITEKEY",
318318 );
319319 const bob = await login("bob");
320 expect((await get("/settings/secrets", bob)).status).toBe(404);
320 expect((await get("/settings/secrets", bob)).status).toBe(403);
321321 expect(await (await get("/acme", bob)).text()).not.toContain("Seal");
322322
323323 const set = await fetch(`${servers.url}/settings/secrets`, {
test/security-controls.test.ts +1 −1
@@ -44,7 +44,7 @@ test("an admin sees every control; with no report each says no check recorded",
4444
4545 test("the page is an admin's", async () => {
4646 expect((await get()).status).toBe(303);
47 expect((await get(other)).status).toBe(404);
47 expect((await get(other)).status).toBe(403);
4848 });
4949
5050 test("a recorded check shows its time; a failed one says failed; junk is ignored", async () => {
test/settings-backups-buttons.test.ts +1 −1
@@ -109,7 +109,7 @@ test("Test with nothing typed and nothing sealed says there is nothing to test",
109109 for (const path of ["/settings/backups/test", "/settings/backups/mirror/now"]) {
110110 test(`${path}: a non-admin, an anonymous visitor and a cross-origin post are refused`, async () => {
111111 const asBob = await post(path, bob);
112 expect(asBob.status).toBe(404);
112 expect(asBob.status).toBe(403);
113113 expect(asBob.headers.getSetCookie().join(";")).not.toMatch(/flash/);
114114
115115 const anon = await post(path, "");
test/settings-backups-mirror.test.ts +2 −2
@@ -89,9 +89,9 @@ describe("Settings → Backups mirror", () => {
8989
9090 test("a non-admin and a cross-origin post are refused and change nothing", async () => {
9191 const asBob = await post("/settings/backups/mirror", GOOD, bob);
92 expect(asBob.status).toBe(404);
92 expect(asBob.status).toBe(403);
9393 const asBobClear = await post("/settings/backups/mirror/clear", {}, bob);
94 expect(asBobClear.status).toBe(404);
94 expect(asBobClear.status).toBe(403);
9595 const cross = await post("/settings/backups/mirror", GOOD, admin, "https://evil.example");
9696 expect(cross.status).toBe(403);
9797 expect(mirrorSealedSet(app.db)).toEqual([]);
test/settings-cloud-forms.test.ts +1 −1
@@ -104,7 +104,7 @@ describe("cloud account forms in Settings", () => {
104104 test("a non-admin and a cross-origin post are refused for each, and the account stays", async () => {
105105 const before = JSON.stringify(listCloudAccounts(app.db));
106106 for (const path of ["rotate", "rename", "remove"]) {
107 expect((await post(path, member, form(path))).status).toBe(404);
107 expect((await post(path, member, form(path))).status).toBe(403);
108108 expect((await post(path, admin, form(path), "https://evil.example")).status).toBe(403);
109109 }
110110 expect(JSON.stringify(listCloudAccounts(app.db))).toBe(before);
test/settings-cloud-verify.test.ts +1 −1
@@ -101,7 +101,7 @@ describe("POST /settings/cloud/verify", () => {
101101 const bob = await login("bob");
102102 const alice = await login("alice");
103103 const before = calls;
104 expect((await verify(bob)).status).toBe(404);
104 expect((await verify(bob)).status).toBe(403);
105105 expect((await verify(alice, "https://evil.example")).status).toBe(403);
106106 const anon = await verify("");
107107 expect(anon.headers.get("location") ?? "").not.toBe("/settings/cloud");
test/settings-secret-delete.test.ts +1 −1
@@ -82,7 +82,7 @@ test("a non-admin, a signed-out caller and a cross-origin post are refused; the
8282 await post(undefined, servers.url),
8383 await post(admin, "https://evil.example"),
8484 ];
85 expect(refused[0]!.status).toBe(404);
85 expect(refused[0]!.status).toBe(403);
8686 expect(refused[1]!.headers.get("location")).toStartWith("/signin");
8787 expect(refused[2]!.status).toBe(403);
8888 for (const res of refused) {
test/shield.test.ts +2 −2
@@ -67,8 +67,8 @@ afterAll(async () => {
6767
6868 test("the page is an admin's: a stranger signs in, anyone else finds nothing, and the API refuses them at the admin gate", async () => {
6969 expect((await get("/settings/security")).status).toBe(303);
70 expect((await get("/settings/security", { cookie: bobCookie })).status).toBe(404);
71 expect((await post("signin", { cookie: bobCookie })).status).toBe(404);
70 expect((await get("/settings/security", { cookie: bobCookie })).status).toBe(403);
71 expect((await post("signin", { cookie: bobCookie })).status).toBe(403);
7272 // The same answer the capacity door gives this token: never a change, never a hint of one.
7373 const capacity = await fetch(`${servers.url}/api/v1/admin/capacity/crew.concurrency`, {
7474 method: "PUT",
test/signins.test.ts +3 −3
@@ -214,14 +214,14 @@ describe("sign-ins: tokens and browsers, seen and ended", () => {
214214 expect(
215215 (await fetch(`${base}/settings/accounts/bo`, { headers: { cookie: adaCookie } }))
216216 .status,
217 ).toBe(404);
217 ).toBe(403);
218218 expect(
219219 (await post(`/settings/accounts/bo/tokens/${victimToken.id}/delete`, adaCookie))
220220 .status,
221 ).toBe(404);
221 ).toBe(403);
222222 expect(
223223 (await post(`/settings/accounts/bo/sessions/${id}/delete`, adaCookie)).status,
224 ).toBe(404);
224 ).toBe(403);
225225 // Strangers too.
226226 expect(
227227 (await fetch(`${base}/api/v1/users/bo/signins`)).status,
test/snapshot-mirror-page.test.ts +1 −1
@@ -95,7 +95,7 @@ describe("the mirror's admin routes", () => {
9595 const guest = await post(path, full);
9696 expect(guest.status).toBe(303);
9797 expect(guest.headers.get("location")).toContain("/signin");
98 expect((await post(path, full, bob)).status).toBe(404);
98 expect((await post(path, full, bob)).status).toBe(403);
9999 }
100100 expect(sealed()).toEqual([]);
101101 });
test/spend-language.test.ts +2 −2
@@ -39,7 +39,7 @@ test("the spend view is one settings-block under an h2, no card", async () => {
3939 expect(m).toContain('id="spend" data-live="run.queued,run.started,run.finished,usage.*"');
4040 });
4141
42 test("a non-admin gets the one 404", async () => {
42 test("a non-admin gets the one 403", async () => {
4343 const r = await fetch(`${servers.url}/settings/spend`, { headers: { cookie: plain } });
44 expect(r.status).toBe(404);
44 expect(r.status).toBe(403);
4545 });
test/spend-tab.test.ts +2 −2
@@ -89,12 +89,12 @@ test("a guest is sent to sign in", async () => {
8989 expect(await res.text()).not.toContain("12.34");
9090 });
9191
92 test("a signed-in non-admin, even an app's owner, is told not found and sees no figure", async () => {
92 test("a signed-in non-admin, even an app's owner, is refused with 403 and sees no figure", async () => {
9393 const res = await fetch(`${servers.url}/settings/spend`, {
9494 headers: { cookie: await cookieOf("bob") },
9595 });
9696 const body = await res.text();
97 expect(res.status).toBe(404);
97 expect(res.status).toBe(403);
9898 expect(body).not.toContain("12.34");
9999 expect(body).not.toContain("work-plan");
100100 expect(body).not.toContain("<h2>Spend</h2>");
test/stripe.test.ts +1 −1
@@ -233,6 +233,6 @@ describe("connecting stripe", () => {
233233 const saved = await form("secret", { secret: "whsec_another12345" });
234234 expect(await flashOf(saved)).toContain("Signing secret saved.");
235235 const asBob = await fetch(`${servers.url}/settings/billing/stripe/connect`, { method: "POST", redirect: "manual", headers: { authorization: `Bearer ${createToken(app.db, bob.id, "bob", "admin").token}`, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ key: KEY }) });
236 expect(asBob.status).toBe(404);
236 expect(asBob.status).toBe(403);
237237 });
238238 });
test/studio-shipping.test.ts +1 −1
@@ -1064,7 +1064,7 @@ describe("the studio's crew panel on a forge with no agents yet", () => {
10641064 expect(settings).not.toContain('href="/settings/crew"');
10651065
10661066 const refused = await get("/settings/crew", teamAdminCookie);
1067 expect(refused.status).toBe(404);
1067 expect(refused.status).toBe(403);
10681068 });
10691069
10701070 test("the app page's way to add a crew member leads unbroken to the door an agent is made through, with nothing dev-only in the way", async () => {
test/subscription-budget.test.ts +1 −1
@@ -165,7 +165,7 @@ describe("an account's whole-life budget", () => {
165165 redirect: "manual",
166166 },
167167 );
168 expect(refused.status).toBe(404);
168 expect(refused.status).toBe(403);
169169 expect(budgetsOf(app.db).get(starter)!.budgetUsd).toBe(1);
170170 const admin = await signIn("alice");
171171 const set = await fetch(
test/subscriptions.test.ts +3 −3
@@ -666,14 +666,14 @@ describe("the pages", () => {
666666 const page = await fetch(`${base}/settings/subscriptions`, {
667667 headers: { cookie: guest },
668668 });
669 expect(page.status).toBe(404);
669 expect(page.status).toBe(403);
670670 const posted = await fetch(`${base}/settings/subscriptions`, {
671671 method: "POST",
672672 headers: { cookie: guest, origin: base },
673673 body: new URLSearchParams({ name: "mine", token: OAT }),
674674 redirect: "manual",
675675 });
676 expect(posted.status).toBe(404);
676 expect(posted.status).toBe(403);
677677 const work = account();
678678 expect(listSubscriptions(app.db)).toHaveLength(1);
679679 setHeadroom(app.db, work.id, 25);
@@ -683,7 +683,7 @@ describe("the pages", () => {
683683 body: new URLSearchParams({ headroom: "0" }),
684684 redirect: "manual",
685685 });
686 expect(headroom.status).toBe(404);
686 expect(headroom.status).toBe(403);
687687 expect(getHeadroom(app.db, work.id)).toBe(25);
688688 });
689689 });
test/system-memory.test.ts +3 −3
@@ -99,8 +99,8 @@ describe("GET /system", () => {
9999 expect(body).toContain("Heap max");
100100 });
101101
102 test("a non-admin gets 404 and an anonymous visitor is sent to sign in", async () => {
103 expect((await get("bob")).status).toBe(404);
102 test("a non-admin gets 403 and an anonymous visitor is sent to sign in", async () => {
103 expect((await get("bob")).status).toBe(403);
104104 const anon = await fetch(servers.url + "/system", { redirect: "manual" });
105105 expect(anon.status).toBe(303);
106106 });
@@ -144,7 +144,7 @@ describe("reaching /system", () => {
144144
145145 test("a refused visitor gets a page, not bare text", async () => {
146146 const res = await get("bob");
147 expect(res.status).toBe(404);
147 expect(res.status).toBe(403);
148148 expect(await res.text()).toContain("<h1>");
149149 });
150150 });
test/system-stream.test.ts +1 −1
@@ -101,7 +101,7 @@ describe("/system over server-sent events", () => {
101101 const bob = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
102102 expect(bob).not.toBe("");
103103 const stream = await fetch(`${servers.url}/system/stream`, { headers: { cookie: bob } });
104 expect(stream.status).toBe(404);
104 expect(stream.status).toBe(403);
105105 expect(stream.headers.get("content-type") ?? "").not.toContain("text/event-stream");
106106 await stream.body?.cancel();
107107 });
test/system-telemetry.test.ts +1 −1
@@ -227,7 +227,7 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el
227227 expect((await tool(tokens["grok"]!, "system")).error).toBe(true);
228228 expect((await tool(tokens["grok"]!, "flow")).error).toBe(true);
229229 const bobCookie = await login("bob");
230 expect((await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } })).status).toBe(404);
230 expect((await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } })).status).toBe(403);
231231 const bobPage = await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } });
232232 expect(await bobPage.text()).not.toContain("Telemetry readers");
233233 });
test/system-timing.test.ts +3 −4
@@ -41,7 +41,7 @@ test("GET /system names its phases", async () => {
4141 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
4242 });
4343
44 test("a non-owner gets the 404 page and no sample or memory phase", async () => {
44 test("a non-admin is refused at the admin gate, before any reader, sample or memory phase", async () => {
4545 const r = await createUser(app.db, {
4646 origin: "everyday",
4747 username: "bob",
@@ -51,8 +51,7 @@ test("a non-owner gets the 404 page and no sample or memory phase", async () =>
5151 if (!r.ok) throw new Error(r.error);
5252 const bob = createToken(app.db, r.value.id, "t", "admin").token;
5353 const res = await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${bob}` } });
54 expect(res.status).toBe(404);
54 expect(res.status).toBe(403);
5555 const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]);
56 expect(names).toContain("reader");
57 for (const name of ["sample", "caps", "memory", "render"]) expect(names).not.toContain(name);
56 for (const name of ["reader", "sample", "caps", "memory", "render"]) expect(names).not.toContain(name);
5857 });
test/talk-captain.test.ts +2 −2
@@ -206,7 +206,7 @@ describe("Settings › Captain", () => {
206206 expect(text).toContain("Memory");
207207 expect(text).toContain("Morning paper");
208208 expect(text).toContain(STARTING_RULES[0]!);
209 expect((await fetch(`${servers.url}/settings/captain`, { headers: { cookie: cookie(other) } })).status).toBe(404);
209 expect((await fetch(`${servers.url}/settings/captain`, { headers: { cookie: cookie(other) } })).status).toBe(403);
210210 expect((await fetch(`${servers.url}/settings/captain`, { redirect: "manual" })).status).not.toBe(200);
211211
212212 expect((await post(owner, { do: "add", text: "Lend credit only to friends." })).status).toBe(303);
@@ -220,7 +220,7 @@ describe("Settings › Captain", () => {
220220 expect((await post(owner, { do: "routine", kind: "checkin", enabled: "1", every: "30", hour: "" })).status).toBe(303);
221221 expect(listRoutines(app.db, owner.id).find((r) => r.kind === "checkin")?.everyMin).toBe(30);
222222 expect((await post(owner, { do: "routine", kind: "checkin", enabled: "1", every: "3", hour: "" })).status).toBe(422);
223 expect((await post(other, { do: "add", text: "mine" })).status).toBe(404);
223 expect((await post(other, { do: "add", text: "mine" })).status).toBe(403);
224224 });
225225
226226 test("a post from another origin changes nothing", async () => {
test/teams-language.test.ts +4 −4
@@ -88,11 +88,11 @@ test("one POST saves; Recent changes is then a plain h2 and ul, and the total is
8888 expect(main).toMatch(/id="teams-total">\d+%</);
8989 });
9090
91 test("a non-admin still gets 404, on page and post", async () => {
91 test("a non-admin is refused with 403, on page and post", async () => {
9292 const res = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: other }, redirect: "manual" });
93 expect(res.status).toBe(404);
93 expect(res.status).toBe(403);
9494 const own = await fetch(`${servers.url}/settings/teams/design`, { headers: { cookie: other }, redirect: "manual" });
95 expect(own.status).toBe(404);
95 expect(own.status).toBe(403);
9696 const post = await fetch(`${servers.url}/settings/teams/x`, { method: "POST", headers: { cookie: other }, redirect: "manual" });
97 expect(post.status).toBe(404);
97 expect(post.status).toBe(403);
9898 });
test/traffic-history.test.ts +1 −1
@@ -187,7 +187,7 @@ test("the page offers the three windows, for an admin alone, and a loopback requ
187187 expect(text).not.toContain("<table class=\"table\" data-dense>\n <caption class=\"sr-only\">\n User agent");
188188 expect(windowOf("nonsense")).toBe("15m");
189189 const notAdmin = `forge_session=${createSession(app.db, bob.id).token}`;
190 expect((await fetch(`${servers.url}/settings/traffic?window=7d`, { headers: { cookie: notAdmin } })).status).toBe(404);
190 expect((await fetch(`${servers.url}/settings/traffic?window=7d`, { headers: { cookie: notAdmin } })).status).toBe(403);
191191 });
192192
193193 test("an empty window is one sentence, not a card", async () => {
test/traffic-sources.test.ts +4 −3
@@ -257,12 +257,13 @@ describe("review: caps, cache, gates and the terminal door", () => {
257257 expect(readVisits(app.db, scope, since)).toHaveLength(2);
258258 });
259259
260 test("a non-admin gets 404 on both pages and the probe; the pages carry the security floor", async () => {
260 test("a non-admin is refused both pages and the probe; the pages carry the security floor", async () => {
261261 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "guest1", email: "[email protected]", password: "correct-horse-battery", isAdmin: false });
262262 if (!made.ok) throw new Error(made.error);
263263 const plain = `forge_session=${createSession(app.db, made.value.id).token}`;
264 for (const path of ["/settings/traffic/sources", "/ada/none/settings/app/traffic"])
265 expect((await fetch(`${servers.url}${path}`, { headers: { cookie: plain } })).status).toBe(404);
264 // The forge's page is an admin route, refused at the gate; an app's is not found.
265 for (const [path, status] of [["/settings/traffic/sources", 403], ["/ada/none/settings/app/traffic", 404]] as const)
266 expect((await fetch(`${servers.url}${path}`, { headers: { cookie: plain } })).status).toBe(status);
266267 expect((await fetch(`${servers.url}/api/v1/admin/traffic/probe?scope=forge`, { headers: { cookie: plain } })).status).not.toBe(200);
267268 const ok = await fetch(`${servers.url}/settings/traffic/sources`, { headers: { cookie } });
268269 expect(ok.status).toBe(200);
test/traffic-truth.test.ts +1 −1
@@ -339,7 +339,7 @@ test("a stranger's deep read reaches the record with its class, and the board an
339339 expect(text).not.toContain("203.0.113.200");
340340 expect(board.headers.get("cache-control") ?? "").not.toContain("public");
341341
342 expect((await get("/settings/traffic", { cookie: notAdmin })).status).toBe(404);
342 expect((await get("/settings/traffic", { cookie: notAdmin })).status).toBe(403);
343343 expect((await get("/settings/traffic")).status).toBeGreaterThanOrEqual(300);
344344 expect((await get("/api/v1/admin/kpis/probe")).status).toBe(404);
345345 expect((await get("/api/v1/admin/kpis/probe", { authorization: `Bearer ${await mintToken("bob", "read")}` })).status).toBe(404);
test/trusted-fold.test.ts +1 −1
@@ -252,7 +252,7 @@ describe("the doors", () => {
252252 ] as const) {
253253 const res = await post(path, bob.value, body);
254254 await res.arrayBuffer();
255 expect(res.status).toBe(404);
255 expect(res.status).toBe(403);
256256 }
257257 const { listTrusted, foldInStopped } = await import("../src/core/trusted_sources.ts");
258258 expect(listTrusted(f.app.db).map((s) => s.account)).toEqual(["jmoran"]);
test/tui-teams.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("teams in the terminal", () => {
122122 const bob = getUserByName(forge.db, "bob")!;
123123 const cookie = (id: string) => `forge_session=${createSession(forge.db, id).token}`;
124124 const denied = await fetch(`${servers.url}/settings/teams/stream`, { headers: { cookie: cookie(bob.id) } });
125 expect(denied.status).toBe(404);
125 expect(denied.status).toBe(403);
126126 await denied.body?.cancel();
127127 const res = await fetch(`${servers.url}/settings/teams/stream`, { headers: { cookie: cookie(alice.id) } });
128128 expect(res.headers.get("content-type")).toContain("text/event-stream");
test/usage-report.test.ts +1 −1
@@ -111,5 +111,5 @@ test("Usage is one page: the four tabs on every tab, Spend admin-only, one sideb
111111 expect(text.match(/data-nav/g) ?? []).not.toHaveLength(0);
112112 expect(text).not.toMatch(/data-nav[^>]*>[^<]*(<svg[\s\S]*?<\/svg>)?\s*(Capacity|Telemetry)\s*</);
113113 }
114 expect((await get("/settings/spend", "bob")).status).toBe(404);
114 expect((await get("/settings/spend", "bob")).status).toBe(403);
115115 });
test/usage-timing.test.ts +1 −2
@@ -53,7 +53,7 @@ test("GET /settings/usage names its phases", async () => {
5353 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
5454 });
5555
56 test("a non-admin's 403 names session but not usage or render", async () => {
56 test("a non-admin is refused by the admin gate before the page runs: no usage or render phase", async () => {
5757 const bob = await createUser(app.db, {
5858 origin: "seed",
5959 trusted: true,
@@ -74,7 +74,6 @@ test("a non-admin's 403 names session but not usage or render", async () => {
7474 await res.text();
7575 expect(res.status).toBe(403);
7676 const timing = res.headers.get("server-timing") ?? "";
77 expect(timing).toContain("session;");
7877 for (const name of ["patterns", "usage", "render"]) expect(timing).not.toContain(`${name};`);
7978 });
8079
test/web-apps.test.ts +2 −2
@@ -749,7 +749,7 @@ describe("apps, runs and the crew on the web", () => {
749749 // ---- the crew ----
750750
751751 test("the crew section: admin only, and an agent made on the web", async () => {
752 expect((await web(bob, "/settings/crew")).status).toBe(404);
752 expect((await web(bob, "/settings/crew")).status).toBe(403);
753753 expect(await bodyOf(bob, "/settings")).not.toContain(
754754 'href="/settings/crew"',
755755 );
@@ -817,7 +817,7 @@ describe("apps, runs and the crew on the web", () => {
817817 const forged = await web(bob, "/settings/crew/bot", {
818818 form: { model: "opus" },
819819 });
820 expect(forged.status).toBe(404);
820 expect(forged.status).toBe(403);
821821 expect(getAgentByName(app.db, "bot")?.model).toBe("sonnet");
822822 });
823823

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs