fix(housekeeping): canonicalize live cutover links

This commit is contained in:
Simo committed 2026-09-02 21:53:19 +02:00
1 parent 1f10e15c1c
commit c841595458
15 files changed
+107 -30

No files matched your search

+13
View File
@@ -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(""));
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots {
rules: { rules: {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/ase/", "/api/", "/settings/", "/me/"], disallow: ["/ase-next/", "/api/", "/settings/", "/me/"],
}, },
sitemap: `${appUrl}/sitemap.xml`, sitemap: `${appUrl}/sitemap.xml`,
}; };
@@ -41,11 +41,11 @@ describe("BcPageDetail", () => {
page={page} page={page}
items={[]} items={[]}
canEdit={false} 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"'); expect(markup).not.toContain('href="/admin/catalog?catalog=bc"');
}); });
}); });
@@ -86,7 +86,7 @@ export function BcPageDetail({
page, page,
items, items,
canEdit, canEdit,
returnHref = "/ase/economy/catalog", returnHref = "/ase-next/economy/catalog",
}: BcPageDetailProps) { }: BcPageDetailProps) {
const { isPending, run } = useServerAction(); const { isPending, run } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog(); const { confirm, dialog: confirmDialog } = useConfirmDialog();
@@ -1751,7 +1751,7 @@ export function CatalogItemsTable({
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Select a soundtrack to play when this item is used Select a soundtrack to play when this item is used
in-game. Uploaded from{" "} in-game. Uploaded from{" "}
<code>/ase/economy/rewards/sounds</code>. <code>/ase-next/economy/rewards/sounds</code>.
</p> </p>
</div> </div>
</TabsContent> </TabsContent>
@@ -159,7 +159,7 @@ export function CatalogPageForm({
if (!ok) return; if (!ok) return;
run(() => deleteCatalogPage({ id: catalogPage.id }), { run(() => deleteCatalogPage({ id: catalogPage.id }), {
successMessage: "Page deleted.", successMessage: "Page deleted.",
redirectTo: "/ase/economy/catalog", redirectTo: "/ase-next/economy/catalog",
}); });
} }
@@ -394,7 +394,7 @@ export function CatalogPageForm({
{childPages.map((child) => ( {childPages.map((child) => (
<Link <Link
key={child.id} key={child.id}
href={`/ase/economy/catalog/${child.id}`} href={`/ase-next/economy/catalog/${child.id}`}
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm" className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
> >
<span> <span>
+2 -2
View File
@@ -215,7 +215,7 @@ export async function Navigation({ session }: { session: Session | null }) {
) : null} ) : null}
{showAdmin || showMod ? ( {showAdmin || showMod ? (
<Link href="/ase" className="nav-item"> <Link href="/ase-next" className="nav-item">
<Image <Image
src="/assets/images/icons/navigation/home.png" src="/assets/images/icons/navigation/home.png"
alt="" alt=""
@@ -397,7 +397,7 @@ export async function Navigation({ session }: { session: Session | null }) {
) : null} ) : null}
{showAdmin || showMod ? ( {showAdmin || showMod ? (
<Link href="/ase" className="nav-item"> <Link href="/ase-next" className="nav-item">
<Image <Image
src="/assets/images/icons/navigation/home.png" src="/assets/images/icons/navigation/home.png"
alt="" alt=""
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, posix } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const PREVIEW_ENTRYPOINTS = [ const PREVIEW_ENTRYPOINTS = [
@@ -14,13 +15,25 @@ const PREMATURE_UI_ROOTS = [
["src/app/admin", "-next"].join(""), ["src/app/admin", "-next"].join(""),
["src/app", "ase"].join("/"), ["src/app", "ase"].join("/"),
] as const; ] as const;
const STABLE_GLOBAL_SOURCES = [ const CUTOVER_GLOBAL_SOURCES = [
"src/lib/admin/guard.ts", "src/lib/admin/guard.ts",
"src/lib/proxy-access.ts",
"src/components/navigation.tsx", "src/components/navigation.tsx",
"src/components/top-header.tsx",
] as const; ] as const;
function liveSourceFiles(root: string): readonly string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
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", () => { describe("gated Housekeeping preview coexistence", () => {
it("publishes the /ase-next entrypoints and dispatcher", () => { it("publishes the /ase-next entrypoints and dispatcher", () => {
for (const path of PREVIEW_ENTRYPOINTS) { for (const path of PREVIEW_ENTRYPOINTS) {
@@ -58,9 +71,9 @@ describe("gated Housekeeping preview coexistence", () => {
expect(gate).toContain("input.flag"); expect(gate).toContain("input.flag");
}); });
it("leaves stable global links and authorization fallbacks on /admin and /mod", () => { it("canonicalizes Housekeeping links while retaining /admin and /mod", () => {
for (const path of STABLE_GLOBAL_SOURCES) { for (const path of CUTOVER_GLOBAL_SOURCES) {
expect(readFileSync(path, "utf8"), path).not.toContain("/ase-next"); expect(readFileSync(path, "utf8"), path).toContain("/ase-next");
} }
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain( expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
'href="/admin"', '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([]);
});
}); });
+1 -1
View File
@@ -32,7 +32,7 @@ interface RunOptions {
* run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' }) * run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' })
* *
* // Delete with redirect * // Delete with redirect
* run(() => deleteNews(id), { redirectTo: '/ase/content/articles' }) * run(() => deleteNews(id), { redirectTo: '/ase-next/content/articles' })
*/ */
export function useServerAction(options: UseServerActionOptions = {}) { export function useServerAction(options: UseServerActionOptions = {}) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
+4 -1
View File
@@ -139,6 +139,9 @@ describe("requireStaffRateLimited", () => {
vi.mocked(clientIp).mockResolvedValue("1.2.3.4"); vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
vi.mocked(rateLimit).mockResolvedValue({ ok: false }); vi.mocked(rateLimit).mockResolvedValue({ ok: false });
await requireStaffRateLimited(); await requireStaffRateLimited();
expect(redirectSafe).toHaveBeenCalledWith("/ase?error=ratelimit", "/ase"); expect(redirectSafe).toHaveBeenCalledWith(
"/ase-next?error=ratelimit",
"/ase-next",
);
}); });
}); });
+5 -5
View File
@@ -21,7 +21,7 @@ export async function requireHousekeepingCapability(
context ?? (await getHousekeepingCapabilityContext()); context ?? (await getHousekeepingCapabilityContext());
const authorization = authorizeHousekeeping(capabilityContext, requirement); const authorization = authorizeHousekeeping(capabilityContext, requirement);
if (!authorization.ok) redirectSafe("/ase", "/ase"); if (!authorization.ok) redirectSafe("/ase-next", "/ase-next");
return capabilityContext; return capabilityContext;
} }
@@ -48,7 +48,7 @@ export async function requirePermission(
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
redirectSafe("/", "/"); redirectSafe("/", "/");
if (!canAccess(permissions, permission, session.user.rank)) if (!canAccess(permissions, permission, session.user.rank))
redirectSafe("/ase", "/ase"); redirectSafe("/ase-next", "/ase-next");
return { return {
id: session.user.id, id: session.user.id,
rank: session.user.rank, rank: session.user.rank,
@@ -62,7 +62,7 @@ export async function requirePermissionRateLimited(
const staff = await requirePermission(permission); const staff = await requirePermission(permission);
const ip = await clientIp(); const ip = await clientIp();
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) 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; return staff;
} }
@@ -70,7 +70,7 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
const staff = await requireStaff(); const staff = await requireStaff();
const ip = await clientIp(); const ip = await clientIp();
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) 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; return staff;
} }
@@ -106,6 +106,6 @@ export async function requireModPermission(
const { permissions } = await getAdminContext(); const { permissions } = await getAdminContext();
const needed = Array.isArray(permission) ? permission : [permission]; const needed = Array.isArray(permission) ? permission : [permission];
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank)); const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
if (!ok) redirectSafe("/ase", "/ase"); if (!ok) redirectSafe("/ase-next", "/ase-next");
return staff; return staff;
} }
+4 -4
View File
@@ -101,7 +101,7 @@ async function fetchCmsCandidates(
.limit(limit) .limit(limit)
.catch(() => []); .catch(() => []);
const prefix = "/ase/people/support/tickets"; const prefix = "/ase-next/people/support/tickets";
return rows.map((row) => ({ return rows.map((row) => ({
key: `cms-${row.id}`, key: `cms-${row.id}`,
@@ -190,7 +190,7 @@ async function fetchHelpCandidates(
: []; : [];
const usernameById = new Map(users.map((u) => [u.id, u.username])); 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) => ({ return rows.map((row) => ({
key: `help-${row.id}`, key: `help-${row.id}`,
@@ -438,8 +438,8 @@ async function fetchStrictUnifiedTicketInbox(
} }
const prefix = const prefix =
row.kind === "cms" row.kind === "cms"
? "/ase/people/support/tickets" ? "/ase-next/people/support/tickets"
: "/ase/people/support/help-tickets"; : "/ase-next/people/support/help-tickets";
const dateValue = const dateValue =
row.dateValue === null row.dateValue === null
? null ? null
+24 -1
View File
@@ -10,7 +10,7 @@ vi.mock("next/headers", () => ({
headers: mocks.headers, headers: mocks.headers,
})); }));
import { readCsrfCookieToken } from "./security"; import { readCsrfCookieToken, safeRedirect } from "./security";
describe("readCsrfCookieToken", () => { describe("readCsrfCookieToken", () => {
beforeEach(() => { beforeEach(() => {
@@ -39,3 +39,26 @@ describe("readCsrfCookieToken", () => {
await expect(readCsrfCookieToken()).resolves.toBe(""); 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);
}
});
});
+6 -1
View File
@@ -75,7 +75,12 @@ const SAFE_REDIRECT_PATHS = new Set([
function isSafePath(path: string): boolean { function isSafePath(path: string): boolean {
if (!path.startsWith("/")) return false; if (!path.startsWith("/")) return false;
if (SAFE_REDIRECT_PATHS.has(path)) return true; 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; return false;
} }
+1 -1
View File
@@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>` ? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
: "") + : "") +
`<p style="margin-top:16px;color:#888;font-size:12px">` + `<p style="margin-top:16px;color:#888;font-size:12px">` +
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase/system/operations/alerts">view alerts</a></p>`; `Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase-next/system/operations/alerts">view alerts</a></p>`;
try { try {
return await sendMail(to, subject, html); return await sendMail(to, subject, html);