fix: restrict top-level slug route to publishers

This commit is contained in:
Patrick Erichsen
2026-06-23 15:09:07 -07:00
parent a128c101ad
commit cc3fc616e9
4 changed files with 37 additions and 100 deletions
+6 -25
View File
@@ -34,35 +34,16 @@ describe("top-level slug route loader", () => {
resolveTopLevelSlugRouteMock.mockReset();
});
it("redirects plugin slugs to plugin detail pages", async () => {
resolveTopLevelSlugRouteMock.mockResolvedValue({
kind: "plugin",
name: "@openclaw/codex",
href: "/openclaw/plugins/codex",
});
it("returns not found for plugin aliases without matching publishers", async () => {
resolveTopLevelSlugRouteMock.mockResolvedValue(null);
expect(await runLoader("codex")).toEqual({
redirect: {
href: "/openclaw/plugins/codex",
replace: true,
},
});
expect(await runLoader("codex")).toEqual({ notFound: true });
});
it("redirects skill slugs to canonical owner pages", async () => {
resolveTopLevelSlugRouteMock.mockResolvedValue({
kind: "skill",
owner: "ivangdavila",
slug: "codex",
});
it("returns not found for legacy bare skill slugs", async () => {
resolveTopLevelSlugRouteMock.mockResolvedValue(null);
expect(await runLoader("codex")).toEqual({
redirect: {
to: "/$owner/skills/$slug",
params: { owner: "ivangdavila", slug: "codex" },
replace: true,
},
});
expect(await runLoader("expedia")).toEqual({ notFound: true });
});
it("returns publisher profile data for canonical publisher paths", async () => {
+13 -24
View File
@@ -19,29 +19,21 @@ describe("slug route resolution", () => {
queryMock.mockReset();
});
it("resolves Codex to the official OpenClaw plugin", async () => {
await expect(resolveTopLevelSlugRoute("codex")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/codex",
href: "/openclaw/plugins/codex",
});
it("does not resolve top-level OpenClaw plugin aliases without matching publishers", async () => {
queryMock.mockResolvedValue(null);
await expect(resolveTopLevelSlugRoute("codex")).resolves.toBeNull();
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
it("resolves extension slugs to their configured npm package names", async () => {
await expect(resolveTopLevelSlugRoute("anthropic")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/anthropic-provider",
href: "/openclaw/plugins/anthropic-provider",
});
it("resolves extension slugs to their configured npm package names for legacy owner routes", async () => {
await expect(resolveOpenClawPluginSlug("kimi-coding", "openclaw")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/kimi-provider",
href: "/openclaw/plugins/kimi-provider",
});
await expect(resolveTopLevelSlugRoute("diffs-language-pack")).resolves.toEqual({
await expect(resolveOpenClawPluginSlug("diffs-language-pack", "openclaw")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/diffs-language-pack",
href: "/openclaw/plugins/diffs-language-pack",
@@ -62,7 +54,7 @@ describe("slug route resolution", () => {
await expect(resolveOpenClawPluginSlug("codex", "ivangdavila")).resolves.toBeNull();
});
it("falls back to skill slug resolution when no official plugin exists", async () => {
it("does not fall back to skill slug resolution when no publisher exists", async () => {
queryMock.mockResolvedValue(null);
fetchSkillPageDataMock.mockResolvedValue({
owner: "steipete",
@@ -75,11 +67,8 @@ describe("slug route resolution", () => {
},
});
await expect(resolveTopLevelSlugRoute("weather")).resolves.toEqual({
kind: "skill",
owner: "steipete",
slug: "weather",
});
await expect(resolveTopLevelSlugRoute("weather")).resolves.toBeNull();
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
it("resolves publisher handles before legacy bare skill slugs", async () => {
@@ -93,13 +82,13 @@ describe("slug route resolution", () => {
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
it("keeps official OpenClaw aliases ahead of colliding publisher handles", async () => {
it("keeps publisher handles ahead of colliding official OpenClaw aliases", async () => {
queryMock.mockResolvedValue({ _id: "publishers:tencent", handle: "tencent" });
await expect(resolveTopLevelSlugRoute("tencent")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/tencent-provider",
href: "/openclaw/plugins/tencent-provider",
kind: "publisher",
handle: "tencent",
publisher: { _id: "publishers:tencent", handle: "tencent" },
});
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
+14 -32
View File
@@ -3,28 +3,20 @@ import { convexHttp } from "../convex/client";
import { getOpenClawExtensionPackageName } from "./openClawExtensionSlugs";
import { buildPluginDetailHref } from "./pluginRoutes";
import type { PublicPublisherListItem } from "./publicUser";
import { fetchSkillPageData } from "./skillPage";
const OPENCLAW_HANDLE = "openclaw";
type SlugRouteTarget =
| {
kind: "plugin";
name: string;
href: string;
}
| {
kind: "skill";
owner: string;
slug: string;
}
| {
kind: "publisher";
handle: string;
publisher: PublicPublisherListItem;
};
type PluginSlugRouteTarget = {
kind: "plugin";
name: string;
href: string;
};
type PluginSlugRouteTarget = Extract<SlugRouteTarget, { kind: "plugin" }>;
type TopLevelSlugRouteTarget = {
kind: "publisher";
handle: string;
publisher: PublicPublisherListItem;
};
function normalizeSlug(slug: string) {
return slug.trim().toLowerCase();
@@ -49,10 +41,9 @@ export async function resolveOpenClawPluginSlug(
return null;
}
export async function resolveTopLevelSlugRoute(slug: string): Promise<SlugRouteTarget | null> {
const plugin = await resolveOpenClawPluginSlug(slug);
if (plugin) return plugin;
export async function resolveTopLevelSlugRoute(
slug: string,
): Promise<TopLevelSlugRouteTarget | null> {
const publisher = await resolvePublisherHandle(slug);
if (publisher) {
return {
@@ -62,16 +53,7 @@ export async function resolveTopLevelSlugRoute(slug: string): Promise<SlugRouteT
};
}
const data = await fetchSkillPageData(slug);
const owner = data.initialData?.result?.owner?.handle ?? data.owner;
const resolvedSlug = data.initialData?.result?.resolvedSlug ?? slug;
if (!owner || !data.initialData?.result?.skill) return null;
return {
kind: "skill",
owner,
slug: resolvedSlug,
};
return null;
}
async function resolvePublisherHandle(handle: string) {
+4 -19
View File
@@ -1,4 +1,4 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { buildPublisherMeta } from "../lib/og";
import { resolveTopLevelSlugRoute } from "../lib/slugRoute";
import { PublisherProfilePage } from "./user/$handle";
@@ -8,24 +8,9 @@ export const Route = createFileRoute("/$slug")({
const target = await resolveTopLevelSlugRoute(params.slug);
if (!target) throw notFound();
if (target.kind === "plugin") {
throw redirect({
href: target.href,
replace: true,
});
}
if (target.kind === "publisher") {
return {
publisher: target.publisher,
};
}
throw redirect({
to: "/$owner/skills/$slug",
params: { owner: target.owner, slug: target.slug },
replace: true,
});
return {
publisher: target.publisher,
};
},
head: ({ params, loaderData }) => {
if (!loaderData || !("publisher" in loaderData)) return {};