Files
Oleg Isonen 00fcb26afe fix: align path rules with published routes and document headers (#6488)
## Problem

Builder page navigation, Authentication, and HTTP response headers used
different path matchers from published Remix pages. For example, Builder
did not select a `/docs/*` page at `/docs`, and a request for `/%64ocs`
could reach the `/docs` page without matching its Authentication rule.

## Change

- Use Remix's framework-independent router for page selection and
project path rules. Adapt Webstudio named splats and preserve encoded
static path segments.
- Keep Authentication's first matching rule and Headers' ordered
application of every matching rule.
- Preserve previously saved encoded literals such as `/%2A`, `/%3F`, and
`/%3Aid`. They match only their literal encoded pathname and cannot
become wildcards or parameters. Previously accepted malformed encoding
remains a literal path.
- Replace Builder's separate route ranking and URLPattern matcher.
URLPattern remains for page path validation and path editing.
- Use the request URL for the project-domain authentication exemption
instead of trusting `x-forwarded-host`. Generated HTML, XML, and text
routes share one authentication helper.
- Extend the HTTP headers guide and shared path pattern reference with
matching behavior, an iframe example, and verification steps.

## Validation

- Authentication package: 88 tests. New regression cases cover saved
Authentication and Headers rules, literal matching, route ranking,
parameter values, and a marker collision attempt. The regressions failed
before the fix and pass now.
- Project build: 2,484 tests, including 214 route and redirect checks.
SDK: 636 tests. CLI: 1,165 tests. Builder system: 5 tests.
- Typechecks: Authentication, project build, Builder, SDK, and CLI.
Lint, package boundaries, formatting, and diff checks pass.
- The fixture pipeline completed and made no additional changes. The
generated fixture files already changed by this PR remain included.
- Staging project build 196 published successfully using the companion
published-site Worker branch. The edge dispatcher was deployed from its
staging branch to normalize forwarded hosts. Live HTTP checks covered
base and child routes, dynamic pages, redirects, 404s, global headers,
and route-scoped headers. The generated site challenged protected base
and child paths, accepted the route password, and left public routes
accessible. The project domain intentionally skips route-specific auth
and has a separate staging Basic Auth gate, so the successful
route-password request was checked in the generated production runtime.
A forged `x-forwarded-host` no longer changes the hosted project-domain
auth decision.
- An initial CLI run alongside two other full suites timed out in one
fixture API test; that test passed alone, and the full CLI suite passed
when rerun alone.

## Compatibility

- [x] Preserve previously accepted percent-encoded route literals before
merging.
- The old URLPattern test asserting that `/docs/*` does not match
`/docs` was intentionally changed because the published Remix router
does match the base path. Tests for the removed custom route sorter were
replaced with router-selection tests.

## Rollout

- [x] Migrate matching in this repository.
- [x] Update user documentation.
- [x] Verify package consumers and related code.
- [ ] Merge
[webstudio-saas#665](https://github.com/webstudio-is/webstudio-saas/pull/665)
before releasing this PR. The current published-site Worker template
imports the removed `matchesPathnamePattern` export; without the
companion change, a published site fails at Wrangler build.
- [ ] After publishing the new `@webstudio-is/wsauth` version, update
the published-site Worker template’s pinned `0.301.0` dependency, remove
the temporary fallback, and deploy the hosted Worker.

Replaces #6482 so the review tracks the deployed `headers.staging`
branch.
2026-10-02 13:51:39 +02:00

114 lines
3.4 KiB
TypeScript

import { atom, computed } from "nanostores";
import { getPagePath, isPage, type Page, type System } from "@webstudio-is/sdk";
import {
compilePathnamePattern,
tokenizePathnamePattern,
} from "@webstudio-is/project-build/runtime";
import { matchPathnamePattern } from "@webstudio-is/wsauth";
import { toWebstudioParams } from "@webstudio-is/react-sdk";
import { $selectedPage } from "./nano-states/pages";
import { $pages } from "./sync/data-stores";
import { $publishedOrigin } from "./nano-states/misc";
import { executeRuntimeMutation } from "./instance-utils/data";
export const $systemDataByPage = atom(
new Map<Page["id"], Pick<System, "search" | "params">>()
);
const extractParams = (
pattern: string,
path?: string,
fallbackPattern?: string
) => {
const params: System["params"] = {};
const tokens = tokenizePathnamePattern(pattern);
// try to match the first item in history to let user
// see the page without manually entering params
// or selecting them in address bar
let matchedParams: System["params"] | undefined;
if (path) {
const match = matchPathnamePattern(pattern, path);
if (match) {
matchedParams = toWebstudioParams(pattern, match);
} else if (fallbackPattern) {
const fallbackMatch = matchPathnamePattern(fallbackPattern, path);
if (fallbackMatch) {
matchedParams = toWebstudioParams(fallbackPattern, fallbackMatch);
}
}
}
for (const token of tokens) {
if (token.type === "param") {
params[token.name] = matchedParams?.[token.name] ?? undefined;
}
}
return params;
};
export const $currentSystem = computed(
[$publishedOrigin, $selectedPage, $pages, $systemDataByPage],
(origin, page, pages, systemByPage) => {
const system: System = {
search: {},
params: {},
pathname: "/",
origin,
};
if (page === undefined || pages === undefined || !isPage(page)) {
return system;
}
const systemData = systemByPage.get(page.id);
const pagePath = getPagePath(page.id, pages);
const extractedParams = extractParams(
pagePath,
page.history?.[0],
page.path
);
const params = { ...extractedParams, ...systemData?.params };
const pathname = compilePath(pagePath, params) || "/";
return {
search: { ...system.search, ...systemData?.search },
params,
pathname,
origin,
};
}
);
const compilePath = (pattern: string, params: System["params"]) => {
const tokens = tokenizePathnamePattern(pattern);
return compilePathnamePattern(tokens, params);
};
/**
* put new path into the beginning of history
* and drop paths in the end when exceeded 20
*/
const savePathInHistory = (pageId: string, path: string) => {
executeRuntimeMutation({
id: "pages.savePathInHistory",
input: {
pageId,
path,
},
});
};
export const updateCurrentSystem = (
update: Partial<Pick<System, "search" | "params">>
) => {
const page = $selectedPage.get();
if (!isPage(page)) {
return;
}
const systemDataByPage = new Map($systemDataByPage.get());
const systemData = systemDataByPage.get(page.id);
const search = update.search ?? systemData?.search ?? {};
const params = update.params ?? systemData?.params ?? {};
systemDataByPage.set(page.id, { search, params });
$systemDataByPage.set(systemDataByPage);
const pages = $pages.get();
const pagePath = pages ? getPagePath(page.id, pages) : page.path;
savePathInHistory(page.id, compilePath(pagePath, params));
};