diff --git a/src/app/robots.test.ts b/src/app/robots.test.ts new file mode 100644 index 00000000..7881a1c6 --- /dev/null +++ b/src/app/robots.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import robots from "./robots"; + +describe("robots", () => { + it("marks the canonical Housekeeping namespace as private", () => { + const result = robots(); + const rules = Array.isArray(result.rules) ? result.rules : [result.rules]; + const disallow = rules.flatMap((rule) => rule.disallow ?? []); + + expect(disallow).toContain("/ase-next/"); + expect(disallow).not.toContain(["/", "ase", "/"].join("")); + }); +}); diff --git a/src/app/robots.ts b/src/app/robots.ts index c82cafcf..119c90e6 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: "*", allow: "/", - disallow: ["/ase/", "/api/", "/settings/", "/me/"], + disallow: ["/ase-next/", "/api/", "/settings/", "/me/"], }, sitemap: `${appUrl}/sitemap.xml`, }; diff --git a/src/components/admin/catalog/builder-club/bc-manager.test.tsx b/src/components/admin/catalog/builder-club/bc-manager.test.tsx index 3d4f826a..e06084d5 100644 --- a/src/components/admin/catalog/builder-club/bc-manager.test.tsx +++ b/src/components/admin/catalog/builder-club/bc-manager.test.tsx @@ -41,11 +41,11 @@ describe("BcPageDetail", () => { page={page} items={[]} canEdit={false} - returnHref="/ase/economy/catalog" + returnHref="/ase-next/economy/catalog" />, ); - expect(markup).toContain('href="/ase/economy/catalog"'); + expect(markup).toContain('href="/ase-next/economy/catalog"'); expect(markup).not.toContain('href="/admin/catalog?catalog=bc"'); }); }); diff --git a/src/components/admin/catalog/builder-club/bc-manager.tsx b/src/components/admin/catalog/builder-club/bc-manager.tsx index a8dfba4c..1effb2c2 100644 --- a/src/components/admin/catalog/builder-club/bc-manager.tsx +++ b/src/components/admin/catalog/builder-club/bc-manager.tsx @@ -86,7 +86,7 @@ export function BcPageDetail({ page, items, canEdit, - returnHref = "/ase/economy/catalog", + returnHref = "/ase-next/economy/catalog", }: BcPageDetailProps) { const { isPending, run } = useServerAction(); const { confirm, dialog: confirmDialog } = useConfirmDialog(); diff --git a/src/components/admin/catalog/detail/catalog-items-table/catalog-items-table.tsx b/src/components/admin/catalog/detail/catalog-items-table/catalog-items-table.tsx index 7f2b7595..44287b02 100644 --- a/src/components/admin/catalog/detail/catalog-items-table/catalog-items-table.tsx +++ b/src/components/admin/catalog/detail/catalog-items-table/catalog-items-table.tsx @@ -1751,7 +1751,7 @@ export function CatalogItemsTable({

Select a soundtrack to play when this item is used in-game. Uploaded from{" "} - /ase/economy/rewards/sounds. + /ase-next/economy/rewards/sounds.

diff --git a/src/components/admin/catalog/detail/catalog-page-form.tsx b/src/components/admin/catalog/detail/catalog-page-form.tsx index 35527f6e..64b6c0d5 100644 --- a/src/components/admin/catalog/detail/catalog-page-form.tsx +++ b/src/components/admin/catalog/detail/catalog-page-form.tsx @@ -159,7 +159,7 @@ export function CatalogPageForm({ if (!ok) return; run(() => deleteCatalogPage({ id: catalogPage.id }), { successMessage: "Page deleted.", - redirectTo: "/ase/economy/catalog", + redirectTo: "/ase-next/economy/catalog", }); } @@ -394,7 +394,7 @@ export function CatalogPageForm({ {childPages.map((child) => ( diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 280b5a73..ebf5fd74 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -215,7 +215,7 @@ export async function Navigation({ session }: { session: Session | null }) { ) : null} {showAdmin || showMod ? ( - + + { + const path = posix.normalize(join(root, entry.name).replaceAll("\\", "/")); + if (entry.isDirectory()) { + if (path === "src/features/housekeeping/migration") return []; + return liveSourceFiles(path); + } + if (!/\.(?:ts|tsx)$/u.test(entry.name) || entry.name.includes(".test.")) { + return []; + } + return [path]; + }); +} + describe("gated Housekeeping preview coexistence", () => { it("publishes the /ase-next entrypoints and dispatcher", () => { for (const path of PREVIEW_ENTRYPOINTS) { @@ -58,9 +71,9 @@ describe("gated Housekeeping preview coexistence", () => { expect(gate).toContain("input.flag"); }); - it("leaves stable global links and authorization fallbacks on /admin and /mod", () => { - for (const path of STABLE_GLOBAL_SOURCES) { - expect(readFileSync(path, "utf8"), path).not.toContain("/ase-next"); + it("canonicalizes Housekeeping links while retaining /admin and /mod", () => { + for (const path of CUTOVER_GLOBAL_SOURCES) { + expect(readFileSync(path, "utf8"), path).toContain("/ase-next"); } expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain( 'href="/admin"', @@ -78,4 +91,24 @@ describe("gated Housekeeping preview coexistence", () => { ); } }); + + it("keeps live Housekeeping links on the canonical namespace", () => { + const staleRoute = new RegExp( + [ + "/", + "ase", + "(?:[/?#]|[", + '"', + "'", + String.fromCharCode(96), + "]|$)", + ].join(""), + "u", + ); + const violations = liveSourceFiles("src").filter((path) => + staleRoute.test(readFileSync(path, "utf8")), + ); + + expect(violations).toEqual([]); + }); }); diff --git a/src/hooks/use-server-action.ts b/src/hooks/use-server-action.ts index 1263eb66..5d082580 100644 --- a/src/hooks/use-server-action.ts +++ b/src/hooks/use-server-action.ts @@ -32,7 +32,7 @@ interface RunOptions { * run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' }) * * // Delete with redirect - * run(() => deleteNews(id), { redirectTo: '/ase/content/articles' }) + * run(() => deleteNews(id), { redirectTo: '/ase-next/content/articles' }) */ export function useServerAction(options: UseServerActionOptions = {}) { const [isPending, startTransition] = useTransition(); diff --git a/src/lib/admin/guard.test.ts b/src/lib/admin/guard.test.ts index 92f4c0fa..3fe466bf 100644 --- a/src/lib/admin/guard.test.ts +++ b/src/lib/admin/guard.test.ts @@ -139,6 +139,9 @@ describe("requireStaffRateLimited", () => { vi.mocked(clientIp).mockResolvedValue("1.2.3.4"); vi.mocked(rateLimit).mockResolvedValue({ ok: false }); await requireStaffRateLimited(); - expect(redirectSafe).toHaveBeenCalledWith("/ase?error=ratelimit", "/ase"); + expect(redirectSafe).toHaveBeenCalledWith( + "/ase-next?error=ratelimit", + "/ase-next", + ); }); }); diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index 9654bdd8..bbb0cc30 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -21,7 +21,7 @@ export async function requireHousekeepingCapability( context ?? (await getHousekeepingCapabilityContext()); const authorization = authorizeHousekeeping(capabilityContext, requirement); - if (!authorization.ok) redirectSafe("/ase", "/ase"); + if (!authorization.ok) redirectSafe("/ase-next", "/ase-next"); return capabilityContext; } @@ -48,7 +48,7 @@ export async function requirePermission( if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirectSafe("/", "/"); if (!canAccess(permissions, permission, session.user.rank)) - redirectSafe("/ase", "/ase"); + redirectSafe("/ase-next", "/ase-next"); return { id: session.user.id, rank: session.user.rank, @@ -62,7 +62,7 @@ export async function requirePermissionRateLimited( const staff = await requirePermission(permission); const ip = await clientIp(); if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) - redirectSafe("/ase?error=ratelimit", "/ase"); + redirectSafe("/ase-next?error=ratelimit", "/ase-next"); return staff; } @@ -70,7 +70,7 @@ export async function requireStaffRateLimited(): Promise { const staff = await requireStaff(); const ip = await clientIp(); if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) - redirectSafe("/ase?error=ratelimit", "/ase"); + redirectSafe("/ase-next?error=ratelimit", "/ase-next"); return staff; } @@ -106,6 +106,6 @@ export async function requireModPermission( const { permissions } = await getAdminContext(); const needed = Array.isArray(permission) ? permission : [permission]; const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank)); - if (!ok) redirectSafe("/ase", "/ase"); + if (!ok) redirectSafe("/ase-next", "/ase-next"); return staff; } diff --git a/src/lib/admin/ticket-inbox.ts b/src/lib/admin/ticket-inbox.ts index 03ed8157..2158a91a 100644 --- a/src/lib/admin/ticket-inbox.ts +++ b/src/lib/admin/ticket-inbox.ts @@ -101,7 +101,7 @@ async function fetchCmsCandidates( .limit(limit) .catch(() => []); - const prefix = "/ase/people/support/tickets"; + const prefix = "/ase-next/people/support/tickets"; return rows.map((row) => ({ key: `cms-${row.id}`, @@ -190,7 +190,7 @@ async function fetchHelpCandidates( : []; const usernameById = new Map(users.map((u) => [u.id, u.username])); - const prefix = "/ase/people/support/help-tickets"; + const prefix = "/ase-next/people/support/help-tickets"; return rows.map((row) => ({ key: `help-${row.id}`, @@ -438,8 +438,8 @@ async function fetchStrictUnifiedTicketInbox( } const prefix = row.kind === "cms" - ? "/ase/people/support/tickets" - : "/ase/people/support/help-tickets"; + ? "/ase-next/people/support/tickets" + : "/ase-next/people/support/help-tickets"; const dateValue = row.dateValue === null ? null diff --git a/src/lib/foundation/security-csrf.test.ts b/src/lib/foundation/security-csrf.test.ts index 2ff8979b..cb9eced6 100644 --- a/src/lib/foundation/security-csrf.test.ts +++ b/src/lib/foundation/security-csrf.test.ts @@ -10,7 +10,7 @@ vi.mock("next/headers", () => ({ headers: mocks.headers, })); -import { readCsrfCookieToken } from "./security"; +import { readCsrfCookieToken, safeRedirect } from "./security"; describe("readCsrfCookieToken", () => { beforeEach(() => { @@ -39,3 +39,26 @@ describe("readCsrfCookieToken", () => { await expect(readCsrfCookieToken()).resolves.toBe(""); }); }); + +describe("safeRedirect", () => { + it("recognizes only the canonical Housekeeping private prefix", () => { + const canonical = "/ase-next/content/%"; + const legacy = ["/", "ase", "/content/%"].join(""); + const NativeUrl = globalThis.URL; + + try { + vi.stubGlobal( + "URL", + class { + constructor() { + throw new Error("invalid URL"); + } + }, + ); + expect(safeRedirect(canonical, "/")).toBe(canonical); + expect(safeRedirect(legacy, "/")).toBe("/"); + } finally { + vi.stubGlobal("URL", NativeUrl); + } + }); +}); diff --git a/src/lib/foundation/security.ts b/src/lib/foundation/security.ts index 0746f207..26afcbf5 100644 --- a/src/lib/foundation/security.ts +++ b/src/lib/foundation/security.ts @@ -75,7 +75,12 @@ const SAFE_REDIRECT_PATHS = new Set([ function isSafePath(path: string): boolean { if (!path.startsWith("/")) return false; if (SAFE_REDIRECT_PATHS.has(path)) return true; - if (path.startsWith("/ase/") || path.startsWith("/api/")) return true; + if ( + path === "/ase-next" || + path.startsWith("/ase-next/") || + path.startsWith("/api/") + ) + return true; return false; } diff --git a/src/lib/services/alert.ts b/src/lib/services/alert.ts index 21498fc0..39f77d7c 100644 --- a/src/lib/services/alert.ts +++ b/src/lib/services/alert.ts @@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise { ? `${contextRows}
` : "") + `

` + - `Sent by ${escapeHtml(env.HOTEL_NAME)} · view alerts

`; + `Sent by ${escapeHtml(env.HOTEL_NAME)} · view alerts

`; try { return await sendMail(to, subject, html);