Skip to content

Add /_/* redirect route - #4523

Merged
carderne merged 12 commits into
mainfrom
deeplink-route
Aug 7, 2026
Merged

Add /_/* redirect route#4523
carderne merged 12 commits into
mainfrom
deeplink-route

Conversation

@claude

@claude claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Requested by Chris Arderne · Slack thread

Before: there's no stable URL you can put in docs, an email or a Slack message that lands someone on their own project's API keys page — every dashboard URL contains the org, project and environment slugs, so you have to tell people to navigate there by hand.

After: /_/apikeys resolves the signed-in user's current organization, project and environment and redirects to /orgs/{org}/projects/{project}/env/{env}/apikeys. Deeper segments are preserved (/_/runs/run_abc123) and the query string is passed through. If you're not signed in, login happens first and you land on the deeplink afterwards. The preserved suffix drops . and .. segments in both their plain and escaped spellings, so a crafted URL can't escape the resolved environment path.

The /_/ prefix matches how Supabase spells the same idea.

How

New loader-only route apps/webapp/app/routes/[_].$.ts, modelled on the existing redirect-only routes (orgs.$organizationSlug.projects.$projectParam.apikeys.ts, orgs.$organizationSlug.projects.v3.$.ts):

  • requireUser (app/services/session.server.ts) — already redirects to /login?redirectTo=…, so the deeplink survives login.
  • SelectBestEnvironmentPresenter.call({ user }) resolves org + project + environment from dashboard preferences, falling back to the most recently updated project and the user's dev/prod environment.
  • The suffix is resolved by resolveDeeplinkPage against ENV_PAGE_TARGETS in app/utils/deeplinkPages.ts, which mirrors the environment layout's routes (_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.*).
  • An unrecognised first segment redirects to the environment root — a user-supplied path is never used as the redirect target. The query string survives either way, so /_, /_/nonsense and /_/tasks all behave alike.
  • A user with pending invites goes to invitesPath() first, mirroring _app._index/route.tsx — a deeplink is exactly the URL a new invitee is sent, and without this they were offered organization creation while their invite sat unaccepted.
  • If the user has no projects the presenter throws, and we branch to newProjectPath / newOrganizationPath exactly like _app._index/route.tsx does.
  • The suffix is read from new URL(request.url).pathname rather than the splat param, which React Router decodes. The dashboard writes a task id containing a slash as group%2Fmy-task, and the decoded param split that into two segments that matched no route. Segments are passed through as they arrived — re-encoding would turn %2F into %252F.

Why the filename is [_].$.ts

The brackets are Remix's character escape, and they're load-bearing. In the flat-route convention a segment beginning with _ is a pathless layout that contributes nothing to the URL, so a plain _.$.ts would mount this route at /* and swallow the entire site.

createRoutePath skips a segment only when the cooked and the raw spelling both start with _:

// skip pathless layout segments
if (segment.startsWith("_") && rawSegment.startsWith("_")) {
  continue;
}

For [_].$ the escape parser yields segment = "_" but rawSegment = "[_]", so the skip doesn't fire and the segment survives as a literal. Running the repo's own routes directory through flatRoutes() on @remix-run/dev 2.17.5 confirms it:

{ file: "routes/[_].$.ts", id: "routes/[_].$", path: "_/*", parentId: "root" }

The same entry appears in the production server build's route manifest, no route-path collisions are reported across the other 482 routes, and nothing else in the app claims /_ or anything under it. No route file used […] escaping before this one, so the test asserts the resulting pattern (/_/*) directly rather than leaving it implicit.

One consequence: the prefix comparison in deeplinkSuffix no longer folds case. It used to, because React Router compiles route paths with the i flag and /Deeplink/apikeys really did reach the loader — but _ has no case, so the fold became dead code. Page-name folding stays, since /_/APIKeys still has a route to agree with.

Why the map has two paths per entry

Most names are a page in their own right. Three segments under the environment layout are not — they exist only as the parent of param or child routes, so a bare URL matches no route and would 404:

name bare URL resolves landing prefix for deeper segments
tasks no (only tasks.dashboard, tasks.{standard,scheduled}.$taskParam, tasks.stream) the environment root tasks
waitpoints no (only waitpoints.tokens[.$waitpointParam]) waitpoints/tokens waitpoints/tokens
metrics no (only metrics.$dashboardKey, metrics.custom.$dashboardId) — dropped

So each entry carries a landing used for a bare /_/<name> and a prefix that deeper segments hang off; for an ordinary page both are just the name. The two differ only where the page and the things underneath it live in different places: the task list is the environment root (that route is the env _index, titled "Tasks") while task detail pages are under /tasks. A single "prefix the target" rule would fix waitpoints and break tasks. metrics is left out entirely — it's only a legacy 301 shim to dashboards with no page of its own, so it falls back to the environment root rather than being given an invented target. A suffix that already spells out a path under the prefix is kept as written, so the shorthand /_/waitpoints/waitpoint_123 and the longhand /_/waitpoints/tokens/waitpoint_123 agree.

ENV_PAGE_META in app/components/navigation/favoritePages.tsx looked like the natural source for this list, but it only lists segments that need an icon and a label — tasks, agents and settings get theirs from special cases when resolving page metadata — and it says nothing about whether a segment resolves on its own. The route files answer both questions, so they are the source of truth.

No /deeplink/ alias. The route hasn't shipped, so nothing links to the old spelling and there's no compatibility to preserve. Keeping both would mean two route files and a two-valued prefix constant for no gain.

No project-picker fallback page. The presenter always resolves something for a user with at least one project, and the zero-project case already has a well-defined destination (create project / create org), so an intermediate "which project did you mean?" page would never be shown in practice.

✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works

Testing

pnpm run format, pnpm run lint:fix, pnpm run typecheck --filter webapp and pnpm run build --filter webapp are clean — the build matters here because the route filename is what produces the URL. Not yet exercised against a running dashboard — worth clicking through /_/apikeys, /_/tasks, /_/waitpoints, /_/runs?statuses=COMPLETED_SUCCESSFULLY, /_/runs/run_…, /_/nonsense and /_ while signed out and signed in.

app/utils/deeplinkPages.test.ts reads the environment layout's route files and enforces the invariants that keep the map honest:

  • every landing page resolves to a real route — a leaf route or an _index child, since a param route like metrics.$dashboardKey isn't somewhere you can arrive without the param;
  • every deep link that can actually be made resolves, walking the real child routes under each prefix rather than probing one synthetic segment (names whose children are all literal, like settings/general and alerts/new, need that);
  • every segment that resolves on its own has a deeplink name, so a new page can't be quietly forgotten;
  • the mounted path really is /_/*, asserted with matchPath against the prefix constant the loader strips — the one invariant with no in-repo precedent to lean on.

Deliberate exclusions are named in the test: _index (the environment root, which tasks already points at) and queues_ (Remix's layout-opt-out spelling of queues, not a distinct URL). Failures name the offending entry.

Traversal is covered in both spellings, because the two escapes behave differently and it's worth knowing which layer stops each. %2F survives in pathname, so an escaped slash inside an id stays one segment. %2e%2e does not: WHATWG normalises it to .. and resolves it, so /_/runs/%2e%2e/%2e%2e/etc reaches the loader as /etc — outside the prefix, and treated as no suffix. Individually, a segment is dropped when it is ./.., when it decodes to one, or when it isn't decodable. Both parser behaviours are asserted rather than assumed. The redirect loader itself is still untested: there is no existing pattern for testing a redirect-only Remix loader in this repo.


Changelog

Short links like /_/apikeys now take you straight to that page in your current project and environment (.server-changes/deeplink-routes.md).


Generated by Claude Code

Resolves the signed-in user's current organization, project and environment
and redirects to the canonical page, so /deeplink/apikeys lands on
/orgs/{org}/projects/{project}/env/{env}/apikeys.

Only the environment page segments navigation already knows about are
followed (derived from ENV_PAGE_META), so an unrecognised path redirects to
the environment root rather than becoming the redirect target. Deeper
segments and the query string are preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MvNi2jRtYatPQdXFJRK9SW
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f505054

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

The splat arrives percent-decoded from the router, so the preserved remainder
could contain "." / ".." segments and literal "?" or "#" characters. Traversal
segments let a crafted suffix climb back out of the resolved environment path
(/deeplink/apikeys/../../../../../../x resolved to /orgs/x), and a decoded "?"
or "#" became part of the target's query or hash rather than its path.

Drop traversal segments and re-encode each remaining segment when rebuilding
the path. The redirect can no longer leave the resolved environment path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MvNi2jRtYatPQdXFJRK9SW
@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review


Generated by Claude Code

@claude
claude Bot marked this pull request as ready for review August 6, 2026 17:39
devin-ai-integration[bot]

This comment was marked as resolved.

The allowlist came from ENV_PAGE_META, which omits tasks, agents and
settings because those URL shapes are special-cased when resolving page
metadata. Deeplinks to them fell through to the environment root.

Move the list to its own module and populate it from the environment
layout route segments instead, so task, agent and project settings
deeplinks resolve.
devin-ai-integration[bot]

This comment was marked as resolved.

The allowlist is maintained by hand, so it can fall behind when a page is
added. The test derives the expected set from the environment layout's
route filenames and names any segment that drifts.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Observability map

As of f505054.

18/100 over 413 measured of 429 entry points (base 18, no change)

What this PR changed

route base head now failing
/[_]/: new 0 error-classification, request-context

FIX FIRST

  • /api/v1/projects/:projectRef/envvars (sensitive) - auth-boundary, request-context
  • /auth/sso (sensitive) - auth-boundary, request-context
  • /_app/orgs/:organizationSlug/settings/team (sensitive) - error-classification, auth-scope, request-context

AUDIT 3 of 50 sensitive mutations record an actor. 47 without one.
CONTEXT 11 of 413 entry points name a tenant on a failure path. 325 appear only here, 39 of them sensitive, in the JSON rather than the fix list.

What the score is made of
CHECKS
  error-classification  168 applicable,  94 pass,   0 sole, global without it 10
  auth-boundary          62 applicable,  57 pass,   0 sole, global without it 15
  auth-scope             19 applicable,  17 pass,   0 sole, global without it 18
  request-context       413 applicable,  11 pass, 223 sole, global without it 64
  audit-trail            50 applicable,   3 pass,   0 sole, not in the score

The score and findings here are report-only and never gate the merge. Separately, a required test suite keeps this tool's symbol and route lists in sync with the code they name, and can fail a pull request that renames or removes a symbol they reference, or that adds the first route with a segment they anticipate. Each failure names the list to edit. The rules and their reasons: internal-packages/observability-map/README.md.

devin-ai-integration[bot]

This comment was marked as resolved.

A mapped target only applied to a bare name, so /deeplink/waitpoints landed
on waitpoints/tokens but /deeplink/waitpoints/waitpoint_123 became
waitpoints/waitpoint_123, which matches no route. Prefixing the target
unconditionally would break tasks, whose landing is the environment root
but whose detail pages are under /tasks, so each entry now carries both a
landing path and a prefix for deeper segments.

An unrecognised name also kept no query string while a mapped one did,
which became visible once tasks started resolving to the environment root:
three links to the same page behaved two ways. Both now keep it.

The test walks the real child routes under each prefix rather than probing
one synthetic segment, so a target whose deep form stops resolving fails.
devin-ai-integration[bot]

This comment was marked as resolved.

React Router decodes the splat param, so an id containing an escaped slash
arrived split in two: /deeplink/tasks/standard/group%2Fmy-task became
tasks/standard/group/my-task and matched no route, breaking a link copied
from the dashboard. The suffix now comes from the request pathname, which
keeps %2F intact, and its segments are passed through as they arrived
rather than being encoded a second time.

Traversal rejection now also covers the escaped spellings: a segment is
dropped when it decodes to . or .. , or when it is not decodable at all.
new URL already normalises %2e%2e and resolves it, which can move the
pathname out of /deeplink entirely, so a suffix outside the prefix is
treated as absent.

A user with pending invites is also sent to the invites page first, as the
dashboard index does, so an invitee following a deeplink before joining an
organization is not offered organization creation instead.
devin-ai-integration[bot]

This comment was marked as resolved.

React Router compiles route paths with the `i` flag unless a route opts into
`caseSensitive`, so /Deeplink/apikeys reaches this loader. The prefix strip
required the literal lowercase /deeplink/, so the suffix came back empty and the
link landed on the environment root instead of the page. The page-name lookup
had the same problem one level down: /deeplink/APIKeys fell through even though
/env/{env}/APIKeys would have matched.

Fold the case of the prefix and of the first segment only, and resolve the name
to the map's own spelling. Everything after the first segment is left exactly as
written, since task and run ids are case-sensitive.
devin-ai-integration[bot]

This comment was marked as resolved.

claude added 2 commits August 6, 2026 19:02
Folding only the first segment left a prefix that spans more than one segment
half-matched: `Waitpoints/Tokens/wp_123` did not equal `waitpoints/tokens`, so
the graft branch fired on top of it and produced
`waitpoints/tokens/Tokens/wp_123`, which matches no route. The all-lowercase
spelling worked, so this was a regression the previous commit introduced.

Compare as many leading segments as the prefix spans, lowercased, and return the
prefix in the map's own spelling with everything past it exactly as written.
Driven off `prefix` rather than special-cased for waitpoints, so a second
multi-segment entry is covered when it is added.
Escape the underscore in the route filename so it stays a literal URL
segment: a flat-route segment beginning with `_` is a pathless layout, so
`_.$` would have mounted the route at `/*`. `createRoutePath` skips a
segment only when its cooked and raw spellings both start with `_`, and
the raw spelling of `[_]` does not, so `[_].$` serves `/_/*`.

The prefix comparison no longer folds case. It existed because React
Router matches routes case-insensitively and `/Deeplink/apikeys` really
did reach the loader, but `_` has no case, so the fold is dead. Page-name
folding stays, since `/_/APIKeys` still has a route to agree with.

Drops `/deeplink/*` rather than keeping it as an alias; the route has not
shipped, so nothing links to it yet.
@claude claude Bot changed the title Add /deeplink/* redirect route Add /_/* redirect route Aug 7, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

claude added 2 commits August 7, 2026 11:05
The previous test built the route pattern out of DEEPLINK_PATH_PREFIX and
then asserted that pattern equalled the same constant, so it could not
fail if Remix compiled `[_].$.ts` to something else. That is the one thing
worth guarding here: an unescaped `_` reads as a pathless layout, which
would mount this loader at `/*` — a splat over the whole site whose loader
redirects unconditionally.

Run the real routes directory through the same `flatRoutes` the vite
plugin uses, derive the mounted path from the manifest entry for the route
file, and check the constant against that. Also assert no route anywhere
compiles to a bare site-wide splat, which is the assertion that catches
the failure independently of this route.

Verified by mutation: unescaping the filename fails the suite, and a
decoy pathless splat route fails the splat assertion.
Delete the explanatory blocks and every comment that restated the line
below it. What survives: why the route file's underscore is escaped, and
why `.`/`..` segments are dropped. Rename `isUsableSegment` to
`isSafeSegment` and put the intent that was commented into the test
names instead.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread apps/webapp/app/routes/[_].$.ts
@carderne
carderne merged commit dc52941 into main Aug 7, 2026
38 checks passed
@carderne
carderne deleted the deeplink-route branch August 7, 2026 12:21
@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants