Skip to content

Commit c72f501

Browse files
committed
Send deeplinks whose own segment has no page to a page that exists
tasks, waitpoints and metrics only exist as the parent of param routes, so a bare /deeplink/tasks redirected to a URL matching no route and rendered a 404 — worse than the environment root it used to fall back to. Replace the segment allowlist with a map from deeplink name to target path. tasks points at the environment root, which is the task list; waitpoints points at waitpoints/tokens; metrics is dropped, being only a legacy redirect shim with no page of its own. Deeper segments are still kept as given, so task and run detail links keep working. The test now checks that every target resolves to a real route and that no environment page is missing, instead of comparing bare segment names.
1 parent cf24f03 commit c72f501

3 files changed

Lines changed: 165 additions & 76 deletions

File tree

apps/webapp/app/routes/deeplink.$.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,33 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { prisma } from "~/db.server";
33
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
44
import { requireUser } from "~/services/session.server";
5-
import { ENV_PAGE_SEGMENTS } from "~/utils/deeplinkPages";
5+
import { resolveDeeplinkPage } from "~/utils/deeplinkPages";
66
import { newOrganizationPath, newProjectPath, v3EnvironmentPath } from "~/utils/pathBuilder";
77

88
/**
99
* Stable links that don't name an org, project or environment: /deeplink/apikeys redirects to
10-
* /orgs/{org}/projects/{project}/env/{env}/apikeys for whoever is signed in. Only the environment
11-
* pages in ENV_PAGE_SEGMENTS are followed, so an unrecognised path can never become the redirect
12-
* target — it lands on the resolved environment instead.
10+
* /orgs/{org}/projects/{project}/env/{env}/apikeys for whoever is signed in. Only the pages in
11+
* ENV_PAGE_TARGETS are followed, so an unrecognised path can never become the redirect target —
12+
* it lands on the resolved environment instead.
1313
*/
1414
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
1515
const user = await requireUser(request);
1616

17-
//traversal segments are dropped so a crafted suffix can't climb out of the environment path
18-
const segments = (params["*"] ?? "")
19-
.split("/")
20-
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
21-
//deeper segments are kept, so /deeplink/runs/run_123 reaches the run. They arrive decoded, so
22-
//they're re-encoded: a "?" or "#" in a segment must not become the target's query or hash.
23-
const page = ENV_PAGE_SEGMENTS.has(segments[0] ?? "")
24-
? segments.map(encodeURIComponent).join("/")
25-
: undefined;
26-
17+
const page = resolveDeeplinkPage(params["*"] ?? "");
2718
const { search } = new URL(request.url);
2819

2920
const presenter = new SelectBestEnvironmentPresenter();
3021
try {
3122
const { project, organization, environment } = await presenter.call({ user });
3223
const environmentPath = v3EnvironmentPath(organization, project, environment);
3324

34-
return redirect(page ? `${environmentPath}/${page}${search}` : environmentPath);
25+
//an unrecognised path keeps nothing: it lands on the environment as if no suffix was given
26+
if (page === undefined) {
27+
return redirect(environmentPath);
28+
}
29+
30+
//`tasks` targets the environment root, so there is no suffix to append
31+
return redirect(page ? `${environmentPath}/${page}${search}` : `${environmentPath}${search}`);
3532
} catch (_e) {
3633
//the presenter throws when the user has no projects, same as the dashboard index
3734
const organization = await prisma.organization.findFirst({
Lines changed: 93 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,125 @@
1-
import { readdirSync } from "node:fs";
1+
import { existsSync, readdirSync, statSync } from "node:fs";
22
import { join } from "node:path";
33
import { describe, expect, it } from "vitest";
4-
import { ENV_PAGE_SEGMENTS } from "./deeplinkPages";
4+
import { ENV_PAGE_TARGETS, resolveDeeplinkPage } from "./deeplinkPages";
5+
6+
const ROUTES_DIR = join(__dirname, "../routes");
57

68
// Flat-route prefix for every page that renders inside an environment. The trailing dot matters:
79
// it excludes the layout route itself (`…env.$envParam`), which has no segment of its own.
810
const ENV_ROUTE_PREFIX = "_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.";
911

1012
/**
11-
* Segments that are route files but not deeplink targets:
12-
* - `_index` is the environment root, which is already where an unrecognised deeplink lands.
13+
* Segments that are route files but are not deeplink names:
14+
* - `_index` is the environment root. It is where an unrecognised deeplink already lands, and
15+
* `tasks` is the name that points at it.
1316
* - `queues_` is Remix's "opt out of the parent layout" spelling of `queues`, not a distinct URL.
1417
*/
15-
const NOT_DEEPLINKABLE = new Set(["_index", "queues_"]);
18+
const NOT_DEEPLINK_NAMES = new Set(["_index", "queues_"]);
19+
20+
const routeEntries = readdirSync(ROUTES_DIR);
21+
22+
/** A route directory only contributes a route if it actually holds a `route` module. */
23+
function isRouteModule(entry: string): boolean {
24+
const path = join(ROUTES_DIR, entry);
25+
if (!statSync(path).isDirectory()) return true;
26+
return existsSync(join(path, "route.tsx")) || existsSync(join(path, "route.ts"));
27+
}
28+
29+
/**
30+
* The route file that a bare `/env/{env}/{target}` URL matches, or undefined when nothing does.
31+
* `target` may span segments ("waitpoints/tokens"); "" is the environment root.
32+
*
33+
* Only literal route names are considered — a param route (`metrics.$dashboardKey`) is not a page
34+
* you can land on without supplying the param, which is exactly what this needs to reject.
35+
*/
36+
function routeForTarget(target: string): string | undefined {
37+
if (target === "") {
38+
return isRouteModule(`${ENV_ROUTE_PREFIX}_index`) ? `${ENV_ROUTE_PREFIX}_index` : undefined;
39+
}
40+
41+
const base = ENV_ROUTE_PREFIX + target.split("/").join(".");
42+
// A leaf route, or a layout whose index child supplies the bare URL.
43+
return [base, `${base}.tsx`, `${base}.ts`, `${base}._index`].find(
44+
(candidate) => routeEntries.includes(candidate) && isRouteModule(candidate)
45+
);
46+
}
1647

17-
/** The first path segment of every environment page, read off the route filenames. */
48+
/** Every first segment appearing under the environment layout. */
1849
function envRouteSegments(): Set<string> {
19-
const entries = readdirSync(join(__dirname, "../routes"));
2050
const segments = new Set<string>();
21-
22-
for (const entry of entries) {
51+
for (const entry of routeEntries) {
2352
if (!entry.startsWith(ENV_ROUTE_PREFIX)) continue;
2453
// `metrics.$dashboardKey.ts` -> `metrics`, `agents` -> `agents`, `errors._index` -> `errors`
2554
const segment = entry.slice(ENV_ROUTE_PREFIX.length).split(/[./]/)[0];
2655
// Guards against a future `…env.$envParam.tsx` contributing its extension as a segment.
2756
if (!segment || segment === "ts" || segment === "tsx") continue;
28-
if (NOT_DEEPLINKABLE.has(segment)) continue;
2957
segments.add(segment);
3058
}
31-
3259
return segments;
3360
}
3461

35-
describe("deeplink allowlist", () => {
36-
it("matches the environment layout's route segments", () => {
37-
// Sorted arrays rather than sets so a mismatch names the segment that drifted.
38-
expect([...ENV_PAGE_SEGMENTS].sort()).toEqual([...envRouteSegments()].sort());
39-
});
40-
62+
describe("deeplink targets", () => {
4163
it("found the routes directory", () => {
42-
// Guards the test itself: an empty derived set would make the assertion above vacuous
43-
// if the allowlist were ever emptied too.
64+
// Without this, every assertion below would pass vacuously if the glob ever broke.
4465
expect(envRouteSegments().size).toBeGreaterThan(20);
4566
});
4667

47-
it("excludes the environment root and the layout-opt-out spelling", () => {
48-
expect(ENV_PAGE_SEGMENTS.has("_index")).toBe(false);
49-
expect(ENV_PAGE_SEGMENTS.has("queues_")).toBe(false);
50-
// `queues` itself is still reachable — it is the real URL segment.
51-
expect(ENV_PAGE_SEGMENTS.has("queues")).toBe(true);
68+
it("every target resolves to a real environment route", () => {
69+
const unresolved = [...ENV_PAGE_TARGETS.entries()]
70+
.filter(([, target]) => !routeForTarget(target))
71+
.map(([name, target]) => `${name} -> ${target || "(environment root)"}`);
72+
73+
expect(unresolved).toEqual([]);
74+
});
75+
76+
it("every environment page has a deeplink name", () => {
77+
// A segment that resolves bare is a page someone could reasonably want to link to.
78+
const missing = [...envRouteSegments()]
79+
.filter((segment) => !NOT_DEEPLINK_NAMES.has(segment))
80+
.filter((segment) => routeForTarget(segment) && !ENV_PAGE_TARGETS.has(segment))
81+
.sort();
82+
83+
expect(missing).toEqual([]);
5284
});
5385

54-
it("includes the pages that ENV_PAGE_META omits", () => {
55-
// These have no entry in ENV_PAGE_META (their icon/label is special-cased when resolving
56-
// page metadata), which is why the allowlist is derived from routes and not from that map.
57-
for (const segment of ["tasks", "agents", "settings"]) {
58-
expect(ENV_PAGE_SEGMENTS.has(segment)).toBe(true);
86+
it("names whose own segment 404s are redirected, not mapped to themselves", () => {
87+
// These exist only as the parent of param/child routes, so a bare URL matches no route.
88+
for (const segment of ["tasks", "waitpoints", "metrics"]) {
89+
expect(routeForTarget(segment)).toBeUndefined();
5990
}
91+
92+
// `tasks` and `waitpoints` therefore point somewhere else; `metrics` is only a legacy redirect
93+
// shim with no page of its own, so it is deliberately not a deeplink name at all.
94+
expect(ENV_PAGE_TARGETS.get("tasks")).toBe("");
95+
expect(ENV_PAGE_TARGETS.get("waitpoints")).toBe("waitpoints/tokens");
96+
expect(ENV_PAGE_TARGETS.has("metrics")).toBe(false);
97+
});
98+
});
99+
100+
describe("resolveDeeplinkPage", () => {
101+
it("maps a bare name to its landing page", () => {
102+
expect(resolveDeeplinkPage("apikeys")).toBe("apikeys");
103+
expect(resolveDeeplinkPage("waitpoints")).toBe("waitpoints/tokens");
104+
expect(resolveDeeplinkPage("tasks")).toBe("");
105+
});
106+
107+
it("keeps deeper segments, which address a real sub-route", () => {
108+
expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123");
109+
expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task");
110+
expect(resolveDeeplinkPage("waitpoints/tokens")).toBe("waitpoints/tokens");
111+
});
112+
113+
it("rejects a name that is not a page", () => {
114+
expect(resolveDeeplinkPage("")).toBeUndefined();
115+
expect(resolveDeeplinkPage("nonsense")).toBeUndefined();
116+
expect(resolveDeeplinkPage("metrics")).toBeUndefined();
117+
});
118+
119+
it("drops traversal segments and encodes the rest", () => {
120+
expect(resolveDeeplinkPage("runs/../../../etc/passwd")).toBe("runs/etc/passwd");
121+
expect(resolveDeeplinkPage("../runs")).toBe("runs");
122+
expect(resolveDeeplinkPage("runs/a?b#c")).toBe("runs/a%3Fb%23c");
123+
expect(resolveDeeplinkPage("runs//run_1")).toBe("runs/run_1");
60124
});
61125
});
Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,63 @@
11
/**
2-
* Pages that /deeplink/* is allowed to redirect to. This mirrors the first path segment of the
3-
* environment-layout routes (`_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.*`),
4-
* so add an entry here when a new page is added under that layout.
2+
* Where each /deeplink/<name> lands, relative to the resolved environment. Most names are a page
3+
* in their own right and map to themselves. A few exist only as the parent of param routes
4+
* (`tasks.standard.$taskParam`, `waitpoints.tokens`) — a bare `/tasks` matches no route and would
5+
* 404 — so those map to the page a user actually wants instead.
6+
*
7+
* This mirrors the environment-layout routes
8+
* (`_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.*`): add a page here when one
9+
* is added there. `deeplinkPages.test.ts` checks every target against the route files and fails if
10+
* a page is missing or a target stops resolving.
511
*/
6-
export const ENV_PAGE_SEGMENTS: ReadonlySet<string> = new Set([
7-
"agents",
8-
"alerts",
9-
"apikeys",
10-
"batches",
11-
"branches",
12-
"bulk-actions",
13-
"concurrency",
14-
"dashboards",
15-
"deployments",
16-
"dev-branches",
17-
"environment-variables",
18-
"errors",
19-
"limits",
20-
"logs",
21-
"metrics",
22-
"models",
23-
"playground",
24-
"prompts",
25-
"query",
26-
"queues",
27-
"regions",
28-
"runs",
29-
"schedules",
30-
"sessions",
31-
"settings",
32-
"tasks",
33-
"test",
34-
"waitpoints",
12+
export const ENV_PAGE_TARGETS: ReadonlyMap<string, string> = new Map([
13+
["agents", "agents"],
14+
["alerts", "alerts"],
15+
["apikeys", "apikeys"],
16+
["batches", "batches"],
17+
["branches", "branches"],
18+
["bulk-actions", "bulk-actions"],
19+
["concurrency", "concurrency"],
20+
["dashboards", "dashboards"],
21+
["deployments", "deployments"],
22+
["dev-branches", "dev-branches"],
23+
["environment-variables", "environment-variables"],
24+
["errors", "errors"],
25+
["limits", "limits"],
26+
["logs", "logs"],
27+
["models", "models"],
28+
["playground", "playground"],
29+
["prompts", "prompts"],
30+
["query", "query"],
31+
["queues", "queues"],
32+
["regions", "regions"],
33+
["runs", "runs"],
34+
["schedules", "schedules"],
35+
["sessions", "sessions"],
36+
["settings", "settings"],
37+
// The environment root is the task list (its route is the env `_index`, titled "Tasks"), so a
38+
// bare /deeplink/tasks belongs there rather than at the secondary /tasks/dashboard view.
39+
["tasks", ""],
40+
["test", "test"],
41+
["waitpoints", "waitpoints/tokens"],
3542
]);
43+
44+
/**
45+
* The path a deeplink suffix should redirect to, relative to the environment, or undefined when the
46+
* first segment names no page. Returns "" for a target that is the environment root itself.
47+
*
48+
* Segments beyond the first are kept as given, because they address a real sub-route
49+
* (`/deeplink/runs/run_123`, `/deeplink/tasks/standard/my-task`); only a bare name uses the mapped
50+
* landing page. They arrive decoded, so they are re-encoded: a "?" or "#" in a segment must not
51+
* become the target's query or hash.
52+
*/
53+
export function resolveDeeplinkPage(splat: string): string | undefined {
54+
//traversal segments are dropped so a crafted suffix can't climb out of the environment path
55+
const segments = splat
56+
.split("/")
57+
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
58+
59+
const target = ENV_PAGE_TARGETS.get(segments[0] ?? "");
60+
if (target === undefined) return undefined;
61+
62+
return segments.length > 1 ? segments.map(encodeURIComponent).join("/") : target;
63+
}

0 commit comments

Comments
 (0)