fix(web): canonicalize scoped plugin paths

This commit is contained in:
Vincent Koc
2026-05-03 09:35:02 -07:00
parent 343781a668
commit 59fc54ff64
5 changed files with 221 additions and 0 deletions
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const redirectMock = vi.fn((options: unknown) => ({ redirect: options }));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: unknown) => ({ __config: config }),
notFound: () => ({ notFound: true }),
redirect: (options: unknown) => redirectMock(options),
}));
type RedirectRoute = {
__config: {
beforeLoad: (args: { location: { pathname: string }; params: Record<string, string> }) => never;
};
};
async function loadRoute(path: string): Promise<RedirectRoute> {
return ((await import(path)) as { Route: RedirectRoute }).Route;
}
describe("scoped plugin route redirects", () => {
beforeEach(() => {
redirectMock.mockClear();
});
it("canonicalizes raw scoped plugin detail paths", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name");
expect(() =>
route.__config.beforeLoad({
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio" },
params: { scope: "@clawkit", name: "clawkit-creative-studio" },
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/plugins/%40clawkit%2Fclawkit-creative-studio",
statusCode: 308,
});
});
it("canonicalizes raw scoped plugin security paths", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name/security/$scanner");
expect(() =>
route.__config.beforeLoad({
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio/security/virustotal" },
params: { scope: "@clawkit", name: "clawkit-creative-studio", scanner: "virustotal" },
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/plugins/%40clawkit%2Fclawkit-creative-studio/security/virustotal",
statusCode: 308,
});
});
it("preserves nested security paths through the scoped plugin parent", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name");
expect(() =>
route.__config.beforeLoad({
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio/security/virustotal" },
params: { scope: "@clawkit", name: "clawkit-creative-studio" },
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/plugins/%40clawkit%2Fclawkit-creative-studio/security/virustotal",
statusCode: 308,
});
});
it("canonicalizes raw scoped legacy package paths", async () => {
const route = await loadRoute("../routes/packages/$scope/$name");
expect(() =>
route.__config.beforeLoad({
location: { pathname: "/packages/@clawkit/clawkit-creative-studio" },
params: { scope: "@clawkit", name: "clawkit-creative-studio" },
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/plugins/%40clawkit%2Fclawkit-creative-studio",
statusCode: 308,
});
});
});
+73
View File
@@ -35,9 +35,12 @@ import { Route as PackagesNameRouteImport } from './routes/packages/$name'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as PluginsScopeNameRouteImport } from './routes/plugins/$scope/$name'
import { Route as PackagesScopeNameRouteImport } from './routes/packages/$scope/$name'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
import { Route as PluginsScopeNameSecurityScannerRouteImport } from './routes/plugins/$scope/$name/security/$scanner'
const UploadRoute = UploadRouteImport.update({
id: '/upload',
@@ -169,6 +172,16 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
path: '/$owner/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const PluginsScopeNameRoute = PluginsScopeNameRouteImport.update({
id: '/plugins/$scope/$name',
path: '/plugins/$scope/$name',
getParentRoute: () => rootRouteImport,
} as any)
const PackagesScopeNameRoute = PackagesScopeNameRouteImport.update({
id: '/packages/$scope/$name',
path: '/packages/$scope/$name',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -186,6 +199,12 @@ const OwnerSlugSecurityScannerRoute =
path: '/security/$scanner',
getParentRoute: () => OwnerSlugRoute,
} as any)
const PluginsScopeNameSecurityScannerRoute =
PluginsScopeNameSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => PluginsScopeNameRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -215,8 +234,11 @@ export interface FileRoutesByFullPath {
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -246,8 +268,11 @@ export interface FileRoutesByTo {
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -278,8 +303,11 @@ export interface FileRoutesById {
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -311,8 +339,11 @@ export interface FileRouteTypes {
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -342,8 +373,11 @@ export interface FileRouteTypes {
| '/souls'
| '/users'
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
id:
| '__root__'
| '/'
@@ -373,8 +407,11 @@ export interface FileRouteTypes {
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -404,6 +441,8 @@ export interface RootRouteChildren {
SkillsIndexRoute: typeof SkillsIndexRoute
SoulsIndexRoute: typeof SoulsIndexRoute
UsersIndexRoute: typeof UsersIndexRoute
PackagesScopeNameRoute: typeof PackagesScopeNameRoute
PluginsScopeNameRoute: typeof PluginsScopeNameRouteWithChildren
}
declare module '@tanstack/react-router' {
@@ -590,6 +629,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/plugins/$scope/$name': {
id: '/plugins/$scope/$name'
path: '/plugins/$scope/$name'
fullPath: '/plugins/$scope/$name'
preLoaderRoute: typeof PluginsScopeNameRouteImport
parentRoute: typeof rootRouteImport
}
'/packages/$scope/$name': {
id: '/packages/$scope/$name'
path: '/packages/$scope/$name'
fullPath: '/packages/$scope/$name'
preLoaderRoute: typeof PackagesScopeNameRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/$slug/settings': {
id: '/$owner/$slug/settings'
path: '/settings'
@@ -611,6 +664,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugSecurityScannerRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/plugins/$scope/$name/security/$scanner': {
id: '/plugins/$scope/$name/security/$scanner'
path: '/security/$scanner'
fullPath: '/plugins/$scope/$name/security/$scanner'
preLoaderRoute: typeof PluginsScopeNameSecurityScannerRouteImport
parentRoute: typeof PluginsScopeNameRoute
}
}
}
@@ -640,6 +700,17 @@ const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
PluginsNameRouteChildren,
)
interface PluginsScopeNameRouteChildren {
PluginsScopeNameSecurityScannerRoute: typeof PluginsScopeNameSecurityScannerRoute
}
const PluginsScopeNameRouteChildren: PluginsScopeNameRouteChildren = {
PluginsScopeNameSecurityScannerRoute: PluginsScopeNameSecurityScannerRoute,
}
const PluginsScopeNameRouteWithChildren =
PluginsScopeNameRoute._addFileChildren(PluginsScopeNameRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
@@ -667,6 +738,8 @@ const rootRouteChildren: RootRouteChildren = {
SkillsIndexRoute: SkillsIndexRoute,
SoulsIndexRoute: SoulsIndexRoute,
UsersIndexRoute: UsersIndexRoute,
PackagesScopeNameRoute: PackagesScopeNameRoute,
PluginsScopeNameRoute: PluginsScopeNameRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+17
View File
@@ -0,0 +1,17 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
function scopedPluginPath(scope: string, name: string) {
if (!scope.startsWith("@") || !name) {
throw notFound();
}
return `/plugins/${encodeURIComponent(`${scope}/${name}`)}`;
}
export const Route = createFileRoute("/packages/$scope/$name")({
beforeLoad: ({ params }) => {
throw redirect({
href: scopedPluginPath(params.scope, params.name),
statusCode: 308,
});
},
});
+29
View File
@@ -0,0 +1,29 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
function scopedPluginPath(scope: string, name: string, suffix = "") {
if (!scope.startsWith("@") || !name) {
throw notFound();
}
return `/plugins/${encodeURIComponent(`${scope}/${name}`)}${suffix}`;
}
function scopedPluginSuffix(pathname: string, scope: string, name: string) {
const prefix = `/plugins/${scope}/${name}`;
if (!pathname.startsWith(`${prefix}/`)) {
return "";
}
return pathname.slice(prefix.length);
}
export const Route = createFileRoute("/plugins/$scope/$name")({
beforeLoad: ({ location, params }) => {
throw redirect({
href: scopedPluginPath(
params.scope,
params.name,
scopedPluginSuffix(location.pathname, params.scope, params.name),
),
statusCode: 308,
});
},
});
@@ -0,0 +1,17 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
function scopedPluginSecurityPath(scope: string, name: string, scanner: string) {
if (!scope.startsWith("@") || !name) {
throw notFound();
}
return `/plugins/${encodeURIComponent(`${scope}/${name}`)}/security/${encodeURIComponent(scanner)}`;
}
export const Route = createFileRoute("/plugins/$scope/$name/security/$scanner")({
beforeLoad: ({ params }) => {
throw redirect({
href: scopedPluginSecurityPath(params.scope, params.name, params.scanner),
statusCode: 308,
});
},
});