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

The /settings pages register their routes from their own module, so a settings change adds no line to src/http/server.ts #8393

Merged opened this

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

Merged

mason/forge:mason/8309-the-settings-pages-register-their-routes → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/8309-the-settings-pages-register-their-routes was merged into main as 744f621.

Review

Sign in to review this change.
6 files changed +433 −255
AGENTS.md +2
@@ -11,3 +11,5 @@ A new `forge` command is a new file in `src/cli/commands/` (exporting `command`)
1111 A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone, and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`.
1212
1313 A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the index it walks, in the migration that adds it, and a test shows it from the query plan (`test/hot-reads-indexed.test.ts` is the pattern): no `SCAN`, no `TEMP B-TREE`. Judge by plan or row count, never the clock. A read that stays slow with the best index is a summary table, kept as the rows change, before it is a worker.
14
15 A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`.
src/http/server.ts +5 −251
@@ -13,21 +13,15 @@ import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
1313 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
1414 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
1515 import { unsealReleased } from "../actions/sealed.ts";
16 import { parentCallback, parentLinkStart, parentStart } from "./rp.ts";
16 import { parentCallback, parentStart } from "./rp.ts";
1717 import { prerenderComments } from "../web/prerender.ts";
18 import { accessPage } from "../web/pages/access.ts";
19 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
2018 import { systemPage, systemStream } from "../web/pages/system.ts";
21 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
22 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
2319 import { contributePage } from "../web/pages/contribute.ts";
2420 import { pipelinePage } from "../web/pages/pipeline.ts";
25 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
26 import { usagePage } from "../web/pages/usage.ts";
27 import { spendPage } from "../web/pages/spend-tab.ts";
2821 import { resiliencePage } from "../web/pages/resilience.ts";
2922 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
3023 import { measureSubmit } from "./measure.ts";
24 import { settingsRoutes } from "./settings_routes.ts";
3125 import { boardsPage } from "../web/pages/boards.ts";
3226 import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts";
3327 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
@@ -39,14 +33,6 @@ import {
3933 noteCaller,
4034 sweepStanding,
4135 } from "./caller.ts";
42 import {
43 accountAct,
44 retireAct,
45 retirePage,
46 accountsPage,
47 personSignInsPage,
48 signInAct,
49 } from "../web/pages/accounts.ts";
5036 import { defect } from "../kernel/error.ts";
5137 import {
5238 armClose,
@@ -135,13 +121,8 @@ import {
135121 verifyPage,
136122 themeSubmit,
137123 } from "../web/pages/site.ts";
138 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
139124 import { talkConfirm, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
140125 import {
141 passkeyAdd,
142 passkeyDelete,
143 passkeyOptions,
144 passkeysPage,
145126 recoverFinish,
146127 recoverOptions,
147128 recoverPage,
@@ -156,44 +137,9 @@ import { followingSubmit } from "../web/pages/following.ts";
156137 import { reconnectSubmit } from "../web/pages/frozen.ts";
157138 import { activityAtom } from "../web/feeds.ts";
158139 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
159 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
160 import {
161 backupsClear,
162 backupsDestination,
163 backupsDrill,
164 backupsLive,
165 backupsMirror,
166 backupsMirrorClear,
167 backupsMirrorNow,
168 backupsNow,
169 backupsPage,
170 backupsTest,
171 domainsMoved,
172 } from "../web/pages/backups.ts";
173140 import { apiPage, appApiPage } from "../web/pages/api.ts";
174141 import { changelogPage } from "../web/pages/changelog.ts";
175142 import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
176 import {
177 cloudAdd,
178 cloudAudit,
179 cloudAuditPage,
180 cloudRename,
181 cloudRotate,
182 cloudPage,
183 cloudRemove,
184 cloudVerify,
185 cloudWrite,
186 } from "../web/pages/cloud.ts";
187 import {
188 subscriptionAdd,
189 subscriptionDelete,
190 subscriptionsPage,
191 subscriptionBudget,
192 subscriptionHeadroom,
193 subscriptionWake,
194 } from "../web/pages/subscriptions.ts";
195 import { peoplePage, roleAct } from "../web/pages/people.ts";
196 import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts";
197143 import {
198144 changePassword,
199145 iconPng,
@@ -272,7 +218,6 @@ import {
272218 composerSubmit,
273219 tellSubmit,
274220 } from "../web/studio/actions.ts";
275 import { everydayCreate, everydayKeep, everydayOptions, everydaySwitch } from "../web/pages/everyday.ts";
276221 import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts";
277222 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
278223 import { familyPage } from "../web/pages/family.ts";
@@ -358,15 +303,6 @@ import {
358303 fleetGraphLive,
359304 fleetGraphPage,
360305 } from "../web/pages/fleet-graph.ts";
361 import {
362 agentCreate,
363 agentDelete,
364 modelReviewDecline,
365 modelReviewTake,
366 agentPage,
367 agentUpdate,
368 crewPage,
369 } from "../web/pages/crew.ts";
370306 import { fileHoldUpdate } from "../web/pages/file_holds.ts";
371307 import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts";
372308 import {
@@ -442,25 +378,6 @@ import {
442378 teamMemberRemove,
443379 teamPage,
444380 } from "../web/pages/org.ts";
445 import {
446 accountPage,
447 notifyUpdate,
448 edgeLink,
449 edgeUnlink,
450 parentUnlink,
451 accountUpdate,
452 keyCreate,
453 keyDelete,
454 keysPage,
455 passwordPage,
456 resendVerifyLink,
457 passwordUpdate,
458 tokenCreate,
459 sessionDelete,
460 tokenDelete,
461 tokensPage,
462 } from "../web/pages/account.ts";
463 import { completeOAuthCallback } from "../actions/origins.ts";
464381 import {
465382 browseBlob,
466383 browseBranchesPage,
@@ -479,21 +396,7 @@ import {
479396 browsePulls,
480397 browseRaw,
481398 browseTree,
482 mirrorJobPage,
483399 } from "../web/pages/browse.ts";
484 import {
485 originAppCreate,
486 originAppDelete,
487 originConnectToken,
488 originDisconnect,
489 originOAuthStart,
490 originSyncSubmit,
491 originsPage,
492 originReplaceToken,
493 originTrustedHosts,
494 } from "../web/pages/origins.ts";
495 import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts";
496 import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts";
497400 import { pushThrough } from "../origins/push-through.ts";
498401 import { notFoundPage } from "../web/chrome.ts";
499402 import { targetForShortCode } from "../origins/oauth.ts";
@@ -862,8 +765,9 @@ export function buildRouter(): Router<Handler> {
862765 router.add("GET", "/contribute", contributePage);
863766 // The one path a change follows, in four words (#7703); literal, above /:owner.
864767 router.add("GET", "/pipeline", pipelinePage);
865 router.add("GET", "/settings/telemetry", telemetryPage);
866 router.add("POST", "/settings/telemetry", telemetrySet);
768 // Every /settings… page, in its own module (settings_routes.ts); literal, above /:owner.
769 // The usage page is handed the pattern list, so settings_routes.ts never imports server.ts.
770 settingsRoutes(router, knownPatterns);
867771 // Each THREATS.md protection against the last gate; literal, above /:owner.
868772 router.add("GET", "/resilience", resiliencePage);
869773 // A selection's spend and runs over time; literal, above /:owner.
@@ -897,35 +801,10 @@ export function buildRouter(): Router<Handler> {
897801 });
898802 });
899803 router.add("GET", "/runs/:id", runRedirect);
900 router.add("GET", "/settings", accountPage);
901 router.add("POST", "/settings", accountUpdate);
902 router.add("POST", "/settings/notify", notifyUpdate);
903 router.add("POST", "/settings/edge/link", edgeLink);
904 router.add("POST", "/settings/edge/unlink", edgeUnlink);
905 router.add("POST", "/settings/parent/link", parentLinkStart);
906 router.add("POST", "/settings/parent/unlink", parentUnlink);
907 router.add("POST", "/settings/email/verify", resendVerifyLink);
908 router.add("GET", "/settings/password", passwordPage);
909 router.add("POST", "/settings/password", passwordUpdate);
910 // The everyday account: an admin's daily account, apart from admin (#596).
911 router.add("POST", "/settings/everyday", everydayCreate);
912 router.add("POST", "/settings/everyday/options", everydayOptions);
913 router.add("POST", "/settings/everyday/switch", everydaySwitch);
914 router.add("POST", "/settings/everyday/keep", everydayKeep);
915 router.add("GET", "/settings/tokens", tokensPage);
916 router.add("POST", "/settings/tokens", tokenCreate);
917 router.add("POST", "/settings/tokens/:id/delete", tokenDelete);
918 router.add("POST", "/settings/sessions/:id/delete", sessionDelete);
919804 router.add("GET", "/talk", talkView);
920805 router.add("POST", "/talk", talkSend);
921806 router.add("GET", "/talk/stream", talkStream);
922807 router.add("POST", "/talk/confirm/:id", talkConfirm);
923 router.add("GET", "/settings/confirms", confirmsList);
924 router.add("POST", "/settings/confirms/:id", confirmYes);
925 router.add("GET", "/settings/passkeys", passkeysPage);
926 router.add("POST", "/settings/passkeys/options", passkeyOptions);
927 router.add("POST", "/settings/passkeys", passkeyAdd);
928 router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete);
929808 router.add("POST", "/signin/passkey/options", signinOptions);
930809 router.add("POST", "/signin/passkey", signinPasskey);
931810 router.add("GET", "/recover", recoverPage);
@@ -934,33 +813,6 @@ export function buildRouter(): Router<Handler> {
934813 router.add("POST", "/recover/passkey", recoverFinish);
935814 router.add("POST", "/signup/passkey/options", signupOptions);
936815 router.add("POST", "/signup/passkey", signupPasskey);
937 // The crew: a forge admin's section, hidden from everyone else.
938 router.add("GET", "/settings/subscriptions", subscriptionsPage);
939 router.add("POST", "/settings/subscriptions", subscriptionAdd);
940 router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete);
941 router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake);
942 router.add(
943 "POST",
944 "/settings/subscriptions/:id/headroom",
945 subscriptionHeadroom,
946 );
947 router.add("POST", "/settings/subscriptions/:id/budget", subscriptionBudget);
948 router.add("GET", "/settings/secrets", secretsPage);
949 router.add("POST", "/settings/secrets", secretSet);
950 router.add("POST", "/settings/secrets/delete", secretDelete);
951 router.add("GET", "/settings/cloud", cloudPage);
952 router.add("POST", "/settings/cloud", cloudAdd);
953 router.add("POST", "/settings/cloud/verify", cloudVerify);
954 router.add("POST", "/settings/cloud/write", cloudWrite);
955 router.add("POST", "/settings/cloud/audit", cloudAudit);
956 router.add("GET", "/settings/cloud/audit", cloudAuditPage);
957 router.add("POST", "/settings/cloud/rotate", cloudRotate);
958 router.add("POST", "/settings/cloud/rename", cloudRename);
959 router.add("POST", "/settings/cloud/remove", cloudRemove);
960 router.add("GET", "/settings/access", accessPage);
961 router.add("GET", "/settings/resilience", domainsMoved);
962 router.add("GET", "/settings/capacity", capacityPage);
963 router.add("POST", "/settings/capacity", capacitySet);
964816 router.add("GET", "/boards", boardsList);
965817 router.add("GET", "/boards/data", boardsPage);
966818 router.add("POST", "/boards/new", boardNew);
@@ -970,86 +822,12 @@ export function buildRouter(): Router<Handler> {
970822 router.add("GET", "/boards/:slug", boardShow);
971823 router.add("GET", "/boards/:slug/live", boardLive);
972824 router.add("POST", "/boards/:slug/:op", boardPost);
973 router.add("GET", "/settings/flags", flagsPage);
974 router.add("POST", "/settings/flags", flagsSet);
975 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
976 router.add("GET", "/settings/spend", spendPage);
977825 router.add("GET", "/admin/limits", limitsPage);
978826 router.add("POST", "/admin/limits", limitsSet);
979827 router.add("POST", "/admin/quotas", quotasSet);
980828 router.add("POST", "/admin/signup", signupSet);
981829 router.add("POST", "/admin/invites", inviteMint);
982830 router.add("POST", "/admin/invites/:id/revoke", inviteRevoke);
983 router.add("GET", "/settings/backups", backupsPage);
984 router.add("GET", "/settings/backups/live", backupsLive);
985 router.add("POST", "/settings/backups/now", backupsNow);
986 router.add("POST", "/settings/backups/drill", backupsDrill);
987 router.add("POST", "/settings/backups/destination", backupsDestination);
988 router.add("POST", "/settings/backups/test", backupsTest);
989 router.add("POST", "/settings/backups/clear", backupsClear);
990 router.add("POST", "/settings/backups/mirror", backupsMirror);
991 router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear);
992 router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow);
993 router.add("GET", "/settings/accounts", accountsPage);
994 router.add("GET", "/settings/people", peoplePage);
995 router.add("POST", "/settings/people/:username/role", roleAct);
996 router.add("GET", "/settings/model", modelPage);
997 router.add("POST", "/settings/model", modelAdd);
998 router.add("POST", "/settings/accounts/:username/model", grantAct);
999 router.add("POST", "/settings/accounts/:username/ceiling", ceilingAct);
1000 router.add("GET", "/settings/accounts/:username", personSignInsPage);
1001 router.add(
1002 "POST",
1003 "/settings/accounts/:username/tokens/:id/delete",
1004 signInAct("token"),
1005 );
1006 router.add(
1007 "POST",
1008 "/settings/accounts/:username/sessions/:id/delete",
1009 signInAct("session"),
1010 );
1011 router.add(
1012 "POST",
1013 "/settings/accounts/:username/suspend",
1014 accountAct("suspend"),
1015 );
1016 router.add(
1017 "POST",
1018 "/settings/accounts/:username/unsuspend",
1019 accountAct("unsuspend"),
1020 );
1021 router.add("POST", "/settings/accounts/:username/remove", accountAct("remove"));
1022 router.add("GET", "/settings/accounts/:username/retire", retirePage);
1023 router.add("POST", "/settings/accounts/:username/retire", retireAct);
1024 router.add("POST", "/settings/accounts/:username/verify", accountAct("verify"));
1025 router.add("GET", "/settings/crew", crewPage);
1026 router.add("POST", "/settings/crew", agentCreate);
1027 router.add("GET", "/settings/teams", teamsPage);
1028 router.add("GET", "/settings/teams/stream", teamsStream);
1029 router.add("POST", "/settings/teams/:slug", teamSet);
1030 router.add("GET", "/settings/crew/:username", agentPage);
1031 router.add("POST", "/settings/crew/:username", agentUpdate);
1032 router.add("POST", "/settings/crew/:username/delete", agentDelete);
1033 router.add("POST", "/settings/crew/model-review/:id/take", modelReviewTake);
1034 router.add("POST", "/settings/crew/model-review/:id/decline", modelReviewDecline);
1035 // Where an OAuth authorization comes back to. Deliberately a plain route
1036 // and not an API one: it is a browser redirect, possibly from a phone, and
1037 // the only thing it carries is a `state` this forge issued minutes ago.
1038 router.add("GET", "/settings/origins/callback", async (ctx) => {
1039 const done = await completeOAuthCallback(ctx.app, ctx.auth.user, {
1040 state: ctx.url.searchParams.get("state") ?? "",
1041 code: ctx.url.searchParams.get("code") ?? "",
1042 });
1043 const query = !done.ok
1044 ? "error=state"
1045 : done.value.status === "done"
1046 ? "connected=1"
1047 : `error=${encodeURIComponent(done.value.detail.slice(0, 120))}`;
1048 return new Response(null, {
1049 status: 303,
1050 headers: { location: `/settings/origins?${query}` },
1051 });
1052 });
1053831
1054832 // The short link for a connect in flight, and the one route here that is
1055833 // deliberately anonymous: the device typing it is a phone that has never
@@ -1076,26 +854,6 @@ export function buildRouter(): Router<Handler> {
1076854 });
1077855 });
1078856
1079 // Origins. Literal thirds first, so `flows` and `apps` can never be
1080 // captured as an origin id by the parameterized routes below them.
1081 router.add("GET", "/settings/origins", originsPage);
1082 router.add("POST", "/settings/origins/token", originConnectToken);
1083 router.add("POST", "/settings/origins/oauth", originOAuthStart);
1084 router.add("POST", "/settings/origins/apps", originAppCreate);
1085 router.add("POST", "/settings/origins/trusted-hosts", originTrustedHosts);
1086 router.add("POST", "/settings/origins/apps/:id/delete", originAppDelete);
1087 router.add("GET", "/settings/origins/flows/:id", originFlowPage);
1088 router.add("POST", "/settings/origins/flows/:id/poll", originFlowPoll);
1089 router.add("GET", "/settings/origins/:id/repos", originReposPage);
1090 router.add("POST", "/settings/origins/:id/mirror", originMirrorSubmit);
1091 // A mirror is a job with stages, so it has a page of its own to watch it
1092 // on. The literal "mirror" third segment is registered above the :id/:job
1093 // shape for the same reason `flows` and `apps` are.
1094 router.add("GET", "/settings/origins/:id/mirror/:job", mirrorJobPage);
1095 router.add("POST", "/settings/origins/:id/sync", originSyncSubmit);
1096 router.add("POST", "/settings/origins/:id/delete", originDisconnect);
1097 router.add("POST", "/settings/origins/:id/token", originReplaceToken);
1098
1099857 // Browsing a repository on an origin, live and read-only. Registered here,
1100858 // above every parameterized repo route, so `/origins` can never be captured
1101859 // as an owner — the same reservation `/new` and `/go` already make. Each
@@ -1142,10 +900,6 @@ export function buildRouter(): Router<Handler> {
1142900 browseContributeSubmit,
1143901 );
1144902
1145 router.add("GET", "/settings/keys", keysPage);
1146 router.add("POST", "/settings/keys", keyCreate);
1147 router.add("POST", "/settings/keys/:id/delete", keyDelete);
1148
1149903 // git smart HTTP
1150904 router.add("GET", "/:owner/:name.git/info/refs", (ctx) => {
1151905 const service = ctx.url.searchParams.get("service");
src/http/settings_routes.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * Every `/settings…` page route: the account, its tokens, sessions and
3 * passkeys, and a forge admin's sections (subscriptions, secrets, cloud,
4 * backups, accounts, crew, origins, keys). Moved out of `server.ts` (#8309),
5 * unchanged and in their original order; `buildRouter` calls it once, where
6 * the first of them stood, above every `/:owner`-shaped route, and hands
7 * in the pattern list the usage page reads, so this module never imports it.
8 */
9
10 import type { Handler } from "./context.ts";
11 import type { Router } from "./router.ts";
12 import { parentLinkStart } from "./rp.ts";
outdated

This creates a server.ts <-> settings_routes.ts import cycle. Move knownPatterns to its own module and import it from there in both files.

13 import { accessPage } from "../web/pages/access.ts";
14 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
15 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
16 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
17 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
18 import { usagePage } from "../web/pages/usage.ts";
19 import { spendPage } from "../web/pages/spend-tab.ts";
20 import {
21 accountAct,
22 retireAct,
23 retirePage,
24 accountsPage,
25 personSignInsPage,
26 signInAct,
27 } from "../web/pages/accounts.ts";
28 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
29 import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts";
30 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
31 import {
32 backupsClear,
33 backupsDestination,
34 backupsDrill,
35 backupsLive,
36 backupsMirror,
37 backupsMirrorClear,
38 backupsMirrorNow,
39 backupsNow,
40 backupsPage,
41 backupsTest,
42 domainsMoved,
43 } from "../web/pages/backups.ts";
44 import {
45 cloudAdd,
46 cloudAudit,
47 cloudAuditPage,
48 cloudRename,
49 cloudRotate,
50 cloudPage,
51 cloudRemove,
52 cloudVerify,
53 cloudWrite,
54 } from "../web/pages/cloud.ts";
55 import {
56 subscriptionAdd,
57 subscriptionDelete,
58 subscriptionsPage,
59 subscriptionBudget,
60 subscriptionHeadroom,
61 subscriptionWake,
62 } from "../web/pages/subscriptions.ts";
63 import { peoplePage, roleAct } from "../web/pages/people.ts";
64 import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts";
65 import {
66 everydayCreate,
67 everydayKeep,
68 everydayOptions,
69 everydaySwitch,
70 } from "../web/pages/everyday.ts";
71 import {
72 agentCreate,
73 agentDelete,
74 modelReviewDecline,
75 modelReviewTake,
76 agentPage,
77 agentUpdate,
78 crewPage,
79 } from "../web/pages/crew.ts";
80 import {
81 accountPage,
82 notifyUpdate,
83 edgeLink,
84 edgeUnlink,
85 parentUnlink,
86 accountUpdate,
87 keyCreate,
88 keyDelete,
89 keysPage,
90 passwordPage,
91 resendVerifyLink,
92 passwordUpdate,
93 tokenCreate,
94 sessionDelete,
95 tokenDelete,
96 tokensPage,
97 } from "../web/pages/account.ts";
98 import { completeOAuthCallback } from "../actions/origins.ts";
99 import { mirrorJobPage } from "../web/pages/browse.ts";
100 import {
101 originAppCreate,
102 originAppDelete,
103 originConnectToken,
104 originDisconnect,
105 originOAuthStart,
106 originSyncSubmit,
107 originsPage,
108 originReplaceToken,
109 originTrustedHosts,
110 } from "../web/pages/origins.ts";
111 import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts";
112 import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts";
113
114 export function settingsRoutes(router: Router<Handler>, knownPatterns: () => readonly string[]): void {
115 router.add("GET", "/settings/telemetry", telemetryPage);
116 router.add("POST", "/settings/telemetry", telemetrySet);
117 router.add("GET", "/settings", accountPage);
118 router.add("POST", "/settings", accountUpdate);
119 router.add("POST", "/settings/notify", notifyUpdate);
120 router.add("POST", "/settings/edge/link", edgeLink);
121 router.add("POST", "/settings/edge/unlink", edgeUnlink);
122 router.add("POST", "/settings/parent/link", parentLinkStart);
123 router.add("POST", "/settings/parent/unlink", parentUnlink);
124 router.add("POST", "/settings/email/verify", resendVerifyLink);
125 router.add("GET", "/settings/password", passwordPage);
126 router.add("POST", "/settings/password", passwordUpdate);
127 // The everyday account: an admin's daily account, apart from admin (#596).
128 router.add("POST", "/settings/everyday", everydayCreate);
129 router.add("POST", "/settings/everyday/options", everydayOptions);
130 router.add("POST", "/settings/everyday/switch", everydaySwitch);
131 router.add("POST", "/settings/everyday/keep", everydayKeep);
132 router.add("GET", "/settings/tokens", tokensPage);
133 router.add("POST", "/settings/tokens", tokenCreate);
134 router.add("POST", "/settings/tokens/:id/delete", tokenDelete);
135 router.add("POST", "/settings/sessions/:id/delete", sessionDelete);
136 router.add("GET", "/settings/confirms", confirmsList);
137 router.add("POST", "/settings/confirms/:id", confirmYes);
138 router.add("GET", "/settings/passkeys", passkeysPage);
139 router.add("POST", "/settings/passkeys/options", passkeyOptions);
140 router.add("POST", "/settings/passkeys", passkeyAdd);
141 router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete);
142 // The crew: a forge admin's section, hidden from everyone else.
143 router.add("GET", "/settings/subscriptions", subscriptionsPage);
144 router.add("POST", "/settings/subscriptions", subscriptionAdd);
145 router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete);
146 router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake);
147 router.add(
148 "POST",
149 "/settings/subscriptions/:id/headroom",
150 subscriptionHeadroom,
151 );
152 router.add("POST", "/settings/subscriptions/:id/budget", subscriptionBudget);
153 router.add("GET", "/settings/secrets", secretsPage);
154 router.add("POST", "/settings/secrets", secretSet);
155 router.add("POST", "/settings/secrets/delete", secretDelete);
156 router.add("GET", "/settings/cloud", cloudPage);
157 router.add("POST", "/settings/cloud", cloudAdd);
158 router.add("POST", "/settings/cloud/verify", cloudVerify);
159 router.add("POST", "/settings/cloud/write", cloudWrite);
160 router.add("POST", "/settings/cloud/audit", cloudAudit);
161 router.add("GET", "/settings/cloud/audit", cloudAuditPage);
162 router.add("POST", "/settings/cloud/rotate", cloudRotate);
163 router.add("POST", "/settings/cloud/rename", cloudRename);
164 router.add("POST", "/settings/cloud/remove", cloudRemove);
165 router.add("GET", "/settings/access", accessPage);
166 router.add("GET", "/settings/resilience", domainsMoved);
167 router.add("GET", "/settings/capacity", capacityPage);
168 router.add("POST", "/settings/capacity", capacitySet);
169 router.add("GET", "/settings/flags", flagsPage);
170 router.add("POST", "/settings/flags", flagsSet);
171 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
172 router.add("GET", "/settings/spend", spendPage);
173 router.add("GET", "/settings/backups", backupsPage);
174 router.add("GET", "/settings/backups/live", backupsLive);
175 router.add("POST", "/settings/backups/now", backupsNow);
176 router.add("POST", "/settings/backups/drill", backupsDrill);
177 router.add("POST", "/settings/backups/destination", backupsDestination);
178 router.add("POST", "/settings/backups/test", backupsTest);
179 router.add("POST", "/settings/backups/clear", backupsClear);
180 router.add("POST", "/settings/backups/mirror", backupsMirror);
181 router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear);
182 router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow);
183 router.add("GET", "/settings/accounts", accountsPage);
184 router.add("GET", "/settings/people", peoplePage);
185 router.add("POST", "/settings/people/:username/role", roleAct);
186 router.add("GET", "/settings/model", modelPage);
187 router.add("POST", "/settings/model", modelAdd);
188 router.add("POST", "/settings/accounts/:username/model", grantAct);
189 router.add("POST", "/settings/accounts/:username/ceiling", ceilingAct);
190 router.add("GET", "/settings/accounts/:username", personSignInsPage);
191 router.add(
192 "POST",
193 "/settings/accounts/:username/tokens/:id/delete",
194 signInAct("token"),
195 );
196 router.add(
197 "POST",
198 "/settings/accounts/:username/sessions/:id/delete",
199 signInAct("session"),
200 );
201 router.add(
202 "POST",
203 "/settings/accounts/:username/suspend",
204 accountAct("suspend"),
205 );
206 router.add(
207 "POST",
208 "/settings/accounts/:username/unsuspend",
209 accountAct("unsuspend"),
210 );
211 router.add("POST", "/settings/accounts/:username/remove", accountAct("remove"));
212 router.add("GET", "/settings/accounts/:username/retire", retirePage);
213 router.add("POST", "/settings/accounts/:username/retire", retireAct);
214 router.add("POST", "/settings/accounts/:username/verify", accountAct("verify"));
215 router.add("GET", "/settings/crew", crewPage);
216 router.add("POST", "/settings/crew", agentCreate);
217 router.add("GET", "/settings/teams", teamsPage);
218 router.add("GET", "/settings/teams/stream", teamsStream);
219 router.add("POST", "/settings/teams/:slug", teamSet);
220 router.add("GET", "/settings/crew/:username", agentPage);
221 router.add("POST", "/settings/crew/:username", agentUpdate);
222 router.add("POST", "/settings/crew/:username/delete", agentDelete);
223 router.add("POST", "/settings/crew/model-review/:id/take", modelReviewTake);
224 router.add("POST", "/settings/crew/model-review/:id/decline", modelReviewDecline);
225 // Where an OAuth authorization comes back to. Deliberately a plain route
226 // and not an API one: it is a browser redirect, possibly from a phone, and
227 // the only thing it carries is a `state` this forge issued minutes ago.
228 router.add("GET", "/settings/origins/callback", async (ctx) => {
229 const done = await completeOAuthCallback(ctx.app, ctx.auth.user, {
230 state: ctx.url.searchParams.get("state") ?? "",
231 code: ctx.url.searchParams.get("code") ?? "",
232 });
233 const query = !done.ok
234 ? "error=state"
235 : done.value.status === "done"
236 ? "connected=1"
237 : `error=${encodeURIComponent(done.value.detail.slice(0, 120))}`;
238 return new Response(null, {
239 status: 303,
240 headers: { location: `/settings/origins?${query}` },
241 });
242 });
243 // Origins. Literal thirds first, so `flows` and `apps` can never be
244 // captured as an origin id by the parameterized routes below them.
245 router.add("GET", "/settings/origins", originsPage);
246 router.add("POST", "/settings/origins/token", originConnectToken);
247 router.add("POST", "/settings/origins/oauth", originOAuthStart);
248 router.add("POST", "/settings/origins/apps", originAppCreate);
249 router.add("POST", "/settings/origins/trusted-hosts", originTrustedHosts);
250 router.add("POST", "/settings/origins/apps/:id/delete", originAppDelete);
251 router.add("GET", "/settings/origins/flows/:id", originFlowPage);
252 router.add("POST", "/settings/origins/flows/:id/poll", originFlowPoll);
253 router.add("GET", "/settings/origins/:id/repos", originReposPage);
254 router.add("POST", "/settings/origins/:id/mirror", originMirrorSubmit);
255 // A mirror is a job with stages, so it has a page of its own to watch it
256 // on. The literal "mirror" third segment is registered above the :id/:job
257 // shape for the same reason `flows` and `apps` are.
258 router.add("GET", "/settings/origins/:id/mirror/:job", mirrorJobPage);
259 router.add("POST", "/settings/origins/:id/sync", originSyncSubmit);
260 router.add("POST", "/settings/origins/:id/delete", originDisconnect);
261 router.add("POST", "/settings/origins/:id/token", originReplaceToken);
262 router.add("GET", "/settings/keys", keysPage);
263 router.add("POST", "/settings/keys", keyCreate);
264 router.add("POST", "/settings/keys/:id/delete", keyDelete);
265 }
test/design-audit.test.ts +6 −2
@@ -1,12 +1,16 @@
11 /**
22 * docs/design/audit.md has one entry for every top-level page, and names no
33 * route the router does not have (#2709). The router is read as text: the
4 * static GET paths in src/http/server.ts, less the ones that are not pages.
4 * static GET paths in src/http/server.ts and settings_routes.ts, less the
5 * ones that are not pages.
56 */
67 import { expect, test } from "bun:test";
78 import { readFileSync } from "node:fs";
89
9 const server = readFileSync("src/http/server.ts", "utf8");
10 // The settings routes register from their own module (#8309), so both are read.
11 const server = ["src/http/server.ts", "src/http/settings_routes.ts"]
12 .map((file) => readFileSync(file, "utf8"))
13 .join("\n");
1014 const doc = readFileSync("docs/design/audit.md", "utf8");
1115
1216 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
test/server-timing-sweep.test.ts +5 −2
@@ -1,6 +1,7 @@
11 /**
22 * Every GET page names its Server-Timing phases (#3387). The routes come from
3 * `src/http/server.ts` itself, so a new page is swept the day it is added.
3 * `src/http/server.ts` and `settings_routes.ts` themselves, so a new page is
4 * swept the day it is added.
45 * `UNNAMED` is the debt: each page that still answers with `app` alone, and
56 * the issue that will fix it. The list may not outlive its pages: a page that
67 * starts naming `session` and `render` must come off it.
@@ -52,7 +53,9 @@ const REDIRECTS = new Set([
5253
5354 /** Every GET pattern the router source registers, one-line or wrapped. */
5455 function registeredGets(): Set<string> {
55 const source = readFileSync(join(import.meta.dir, "../src/http/server.ts"), "utf8");
56 const source = ["server.ts", "settings_routes.ts"]
57 .map((file) => readFileSync(join(import.meta.dir, "../src/http", file), "utf8"))
58 .join("\n");
5659 const found = new Set<string>();
5760 for (const m of source.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g)) found.add(m[1]!);
5861 return found;
test/settings-routes.test.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * The `/settings…` routes live in `src/http/settings_routes.ts` (#8309).
3 * The studio's router holds exactly what `settingsRoutes` registers, as one
4 * block in the same order; every settings path still reaches its own route
5 * (nothing registered above the block captures it); and the routes recorded
6 * when they moved out of `server.ts` that are still there keep that order.
7 */
8 import { expect, test } from "bun:test";
9 import type { Handler } from "../src/http/context.ts";
10 import { Router } from "../src/http/router.ts";
11 import { buildRouter } from "../src/http/server.ts";
12 import { settingsRoutes } from "../src/http/settings_routes.ts";
13
14 const isSettings = (pattern: string) => pattern === "/settings" || pattern.startsWith("/settings/");
15 const key = (r: { method: string; pattern: string }) => `${r.method} ${r.pattern}`;
16
17 /** The table as it stood in server.ts when it moved, in registration order. */
18 const RECORDED = `
19 GET /settings/telemetry
20 POST /settings/telemetry
21 GET /settings
22 POST /settings
23 POST /settings/notify
24 POST /settings/edge/link
25 POST /settings/edge/unlink
26 POST /settings/parent/link
27 POST /settings/parent/unlink
28 POST /settings/email/verify
29 GET /settings/password
30 POST /settings/password
31 POST /settings/everyday
32 POST /settings/everyday/options
33 POST /settings/everyday/switch
34 POST /settings/everyday/keep
35 GET /settings/tokens
36 POST /settings/tokens
37 POST /settings/tokens/:id/delete
38 POST /settings/sessions/:id/delete
39 GET /settings/confirms
40 POST /settings/confirms/:id
41 GET /settings/passkeys
42 POST /settings/passkeys/options
43 POST /settings/passkeys
44 POST /settings/passkeys/:id/delete
45 GET /settings/subscriptions
46 POST /settings/subscriptions
47 POST /settings/subscriptions/:id/delete
48 POST /settings/subscriptions/:id/wake
49 POST /settings/subscriptions/:id/headroom
50 POST /settings/subscriptions/:id/budget
51 GET /settings/secrets
52 POST /settings/secrets
53 POST /settings/secrets/delete
54 GET /settings/cloud
55 POST /settings/cloud
56 POST /settings/cloud/verify
57 POST /settings/cloud/write
58 POST /settings/cloud/audit
59 GET /settings/cloud/audit
60 POST /settings/cloud/rotate
61 POST /settings/cloud/rename
62 POST /settings/cloud/remove
63 GET /settings/access
64 GET /settings/resilience
65 GET /settings/capacity
66 POST /settings/capacity
67 GET /settings/flags
68 POST /settings/flags
69 GET /settings/usage
70 GET /settings/spend
71 GET /settings/backups
72 GET /settings/backups/live
73 POST /settings/backups/now
74 POST /settings/backups/drill
75 POST /settings/backups/destination
76 POST /settings/backups/test
77 POST /settings/backups/clear
78 POST /settings/backups/mirror
79 POST /settings/backups/mirror/clear
80 POST /settings/backups/mirror/now
81 GET /settings/accounts
82 GET /settings/people
83 POST /settings/people/:username/role
84 GET /settings/model
85 POST /settings/model
86 POST /settings/accounts/:username/model
87 POST /settings/accounts/:username/ceiling
88 GET /settings/accounts/:username
89 POST /settings/accounts/:username/tokens/:id/delete
90 POST /settings/accounts/:username/sessions/:id/delete
91 POST /settings/accounts/:username/suspend
92 POST /settings/accounts/:username/unsuspend
93 POST /settings/accounts/:username/remove
94 GET /settings/accounts/:username/retire
95 POST /settings/accounts/:username/retire
96 POST /settings/accounts/:username/verify
97 GET /settings/crew
98 POST /settings/crew
99 GET /settings/teams
100 GET /settings/teams/stream
101 POST /settings/teams/:slug
102 GET /settings/crew/:username
103 POST /settings/crew/:username
104 POST /settings/crew/:username/delete
105 POST /settings/crew/model-review/:id/take
106 POST /settings/crew/model-review/:id/decline
107 GET /settings/origins/callback
108 GET /settings/origins
109 POST /settings/origins/token
110 POST /settings/origins/oauth
111 POST /settings/origins/apps
112 POST /settings/origins/trusted-hosts
113 POST /settings/origins/apps/:id/delete
114 GET /settings/origins/flows/:id
115 POST /settings/origins/flows/:id/poll
116 GET /settings/origins/:id/repos
117 POST /settings/origins/:id/mirror
118 GET /settings/origins/:id/mirror/:job
119 POST /settings/origins/:id/sync
120 POST /settings/origins/:id/delete
121 POST /settings/origins/:id/token
122 GET /settings/keys
123 POST /settings/keys
124 POST /settings/keys/:id/delete
125 `.trim().split("\n");
126
127 test("the studio's settings routes are exactly settingsRoutes', as one block in order", () => {
128 const own = new Router<Handler>();
129 settingsRoutes(own, () => []);
130 const mine = own.routes().map(key);
131 expect(mine.every((r) => isSettings(r.split(" ")[1]!))).toBe(true);
132 const all = buildRouter().routes().map(key);
133 const first = all.indexOf(mine[0]!);
134 expect(all.slice(first, first + mine.length)).toEqual(mine);
135 expect(all.filter((r) => isSettings(r.split(" ")[1]!))).toEqual(mine);
136 });
137
138 test("every settings path reaches its own route through the whole table", () => {
139 const router = buildRouter();
140 for (const { method, pattern } of router.routes().filter((r) => isSettings(r.pattern))) {
141 const path = pattern.replace(/:\w+/g, "x");
142 expect(`${method} ${router.match(method, path)?.pattern}`).toBe(`${method} ${pattern}`);
143 }
144 });
145
146 test("the routes recorded when the block moved keep their order, where they remain", () => {
147 const settings = buildRouter().routes().map(key).filter((r) => isSettings(r.split(" ")[1]!));
148 const remaining = new Set(settings);
149 expect(settings.filter((r) => RECORDED.includes(r))).toEqual(RECORDED.filter((r) => remaining.has(r)));
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs