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 {
? ``
: "") +
`` +
- `Sent by ${escapeHtml(env.HOTEL_NAME)} · view alerts
`;
+ `Sent by ${escapeHtml(env.HOTEL_NAME)} · view alerts`;
try {
return await sendMail(to, subject, html);