Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

@@ -33,11 +33,12 @@ describe("admin content module port", () => {
}
});
it("retains public server actions after the ASE route cutover", () => {
for (const module of ["events", "polls"]) {
it("provides locale-free routes and matching server actions", () => {
for (const module of ["events", "polls", "banners", "prefixes"]) {
expect(existsSync(resolve(`src/app/admin/${module}/page.tsx`))).toBe(
true,
);
expect(existsSync(resolve(`src/actions/${module}.ts`))).toBe(true);
}
expect(existsSync(resolve("src/app/admin"))).toBe(false);
expect(existsSync(resolve("src/app/ase/layout.tsx"))).toBe(true);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from "./admin-helpers";
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
interface AdminListConfig<TRow> {
/** Permission slug required to view this page */
permission: string;
/** Default items per page. Default: 20 */
defaultPerPage?: number;
/**
* Runs the actual data query (Drizzle). Receives parsed list params and
* returns the rows + total count for the current page.
*/
fetch: (args: {
search: string;
page: number;
perPage: number;
rawParams: Record<string, string>;
}) => Promise<{ rows: TRow[]; total: number }>;
}
interface AdminListResult<TRow> {
rows: TRow[];
total: number;
page: number;
perPage: number;
lastPage: number;
locale: string;
permissions: PermissionSet;
rank: number;
}
/**
* Generic admin list data fetcher.
* Centralizes the common pattern: auth check, param parsing, pagination and
* row mapping. The caller supplies a Drizzle query via `config.fetch`.
*/
export async function fetchAdminList<TRow>(
config: AdminListConfig<TRow>,
paramsPromise: Promise<{ locale: string }>,
searchParamsPromise: Promise<Record<string, string>>,
): Promise<AdminListResult<TRow>> {
const { locale } = await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, config.permission, session.user.rank)) {
redirect("/admin");
}
const rawParams = await searchParamsPromise;
const sp = new URLSearchParams(rawParams);
const parsed = parseListParams(sp);
const perPage = config.defaultPerPage
? Number(rawParams.perPage) || config.defaultPerPage
: parsed.perPage;
const { rows, total } = await config.fetch({
search: parsed.search,
page: parsed.page,
perPage,
rawParams,
});
const pagination = calcPagination(total, parsed.page, perPage);
return {
rows,
...pagination,
locale,
permissions,
rank: session.user.rank,
};
}
+88
View File
@@ -0,0 +1,88 @@
import { LayoutDashboard, Newspaper, Settings } from "lucide-react";
import { describe, expect, it } from "vitest";
import type { AdminNavGroup } from "@/lib/admin-nav";
import {
ADMIN_NAV_PINNED_HREFS,
applyAdminNavConfig,
parseAdminNavConfig,
serializeAdminNavConfig,
} from "@/lib/admin-nav-config";
const catalog: AdminNavGroup[] = [
{
labelKey: "overview",
icon: LayoutDashboard,
items: [
{ href: "/admin", labelKey: "dashboard", icon: LayoutDashboard },
{ href: "/admin/menu", labelKey: "menu", icon: Settings },
],
},
{
labelKey: "content",
icon: Newspaper,
items: [
{ href: "/admin/articles", labelKey: "articles", icon: Newspaper },
{ href: "/admin/photos", labelKey: "photos", icon: Newspaper },
],
},
{
labelKey: "system",
icon: Settings,
items: [{ href: "/admin/settings", labelKey: "settings", icon: Settings }],
},
];
describe("admin-nav-config", () => {
it("parses empty / invalid as empty config", () => {
expect(parseAdminNavConfig(null)).toEqual({});
expect(parseAdminNavConfig("")).toEqual({});
expect(parseAdminNavConfig("not-json")).toEqual({});
expect(parseAdminNavConfig("[]")).toEqual({});
});
it("reorders groups and items", () => {
const applied = applyAdminNavConfig(catalog, {
groupOrder: ["system", "content", "overview"],
itemOrder: {
content: ["/admin/photos", "/admin/articles"],
},
});
expect(applied.map((g) => g.labelKey)).toEqual([
"system",
"content",
"overview",
]);
expect(applied[1]?.items.map((i) => i.href)).toEqual([
"/admin/photos",
"/admin/articles",
]);
});
it("hides groups and items but keeps pinned hrefs", () => {
const applied = applyAdminNavConfig(catalog, {
hiddenGroups: ["system"],
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
});
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
expect(applied[0]?.items.map((i) => i.href)).toEqual([
"/admin",
"/admin/menu",
]);
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
});
it("round-trips serialize → parse", () => {
const raw = serializeAdminNavConfig({
groupOrder: ["content"],
hiddenGroups: ["system"],
hiddenItems: ["/admin/photos", "/admin"],
itemOrder: { content: ["/admin/articles"] },
});
const parsed = parseAdminNavConfig(raw);
expect(parsed.groupOrder).toEqual(["content"]);
expect(parsed.hiddenGroups).toEqual(["system"]);
expect(parsed.hiddenItems).toEqual(["/admin/photos"]);
expect(parsed.itemOrder).toEqual({ content: ["/admin/articles"] });
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { AdminNavGroup } from "@/lib/admin-nav";
/** website_settings key storing JSON overlay for the admin sidebar. */
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
/** Hrefs that cannot be hidden (dashboard + menu editor). */
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin", "/admin/menu"]);
export type AdminNavConfig = {
/** Group labelKeys in display order. Missing groups append in catalog order. */
groupOrder?: string[];
/** Group labelKeys fully hidden from the sidebar. */
hiddenGroups?: string[];
/** Item hrefs hidden from the sidebar (except pinned). */
hiddenItems?: string[];
/** Per-group item href order. Missing items append in catalog order. */
itemOrder?: Record<string, string[]>;
};
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
/** Parse stored JSON; invalid / empty → empty config. */
export function parseAdminNavConfig(
raw: string | null | undefined,
): AdminNavConfig {
if (!raw?.trim()) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const o = parsed as Record<string, unknown>;
const config: AdminNavConfig = {};
if (isStringArray(o.groupOrder)) config.groupOrder = o.groupOrder;
if (isStringArray(o.hiddenGroups)) config.hiddenGroups = o.hiddenGroups;
if (isStringArray(o.hiddenItems)) config.hiddenItems = o.hiddenItems;
if (
o.itemOrder &&
typeof o.itemOrder === "object" &&
!Array.isArray(o.itemOrder)
) {
const itemOrder: Record<string, string[]> = {};
for (const [k, v] of Object.entries(
o.itemOrder as Record<string, unknown>,
)) {
if (isStringArray(v)) itemOrder[k] = v;
}
config.itemOrder = itemOrder;
}
return config;
} catch {
return {};
}
}
function orderByKeys<T>(
items: T[],
order: string[] | undefined,
keyOf: (item: T) => string,
): T[] {
if (!order?.length) return items;
const byKey = new Map(items.map((item) => [keyOf(item), item]));
const seen = new Set<string>();
const out: T[] = [];
for (const key of order) {
const hit = byKey.get(key);
if (!hit || seen.has(key)) continue;
out.push(hit);
seen.add(key);
}
for (const item of items) {
const key = keyOf(item);
if (seen.has(key)) continue;
out.push(item);
}
return out;
}
/**
* Apply owner overlay (hide + reorder) on top of the code catalog.
* Does not apply ACL — callers filter permissions afterwards.
*/
export function applyAdminNavConfig(
groups: AdminNavGroup[],
config: AdminNavConfig,
): AdminNavGroup[] {
const hiddenGroups = new Set(config.hiddenGroups ?? []);
const hiddenItems = new Set(config.hiddenItems ?? []);
const mapped = groups
.filter((g) => !hiddenGroups.has(g.labelKey))
.map((group) => {
const items = group.items.filter((item) => {
if (ADMIN_NAV_PINNED_HREFS.has(item.href)) return true;
return !hiddenItems.has(item.href);
});
const orderedItems = orderByKeys(
items,
config.itemOrder?.[group.labelKey],
(item) => item.href,
);
return { ...group, items: orderedItems };
})
.filter((group) => group.items.length > 0);
return orderByKeys(mapped, config.groupOrder, (g) => g.labelKey);
}
export function serializeAdminNavConfig(config: AdminNavConfig): string {
return JSON.stringify({
groupOrder: config.groupOrder ?? [],
hiddenGroups: config.hiddenGroups ?? [],
hiddenItems: (config.hiddenItems ?? []).filter(
(href) => !ADMIN_NAV_PINNED_HREFS.has(href),
),
itemOrder: config.itemOrder ?? {},
});
}
+93
View File
@@ -0,0 +1,93 @@
import { Calendar } from "lucide-react";
import { describe, expect, it } from "vitest";
import {
ADMIN_NAV_GROUPS,
collectNavPermissionSlugs,
findAdminHub,
navItemIsAllowed,
} from "./admin-nav";
describe("findAdminHub", () => {
it("matches by prefix", () => {
expect(findAdminHub("/admin/events")?.id).toBe("events");
expect(findAdminHub("/admin/events/123")?.id).toBe("events");
});
it("uses longest-prefix match", () => {
expect(findAdminHub("/admin/tickets/desk")?.id).toBe("tickets");
});
it("returns null for non-hub paths", () => {
expect(findAdminHub("/admin/users")).toBeNull();
expect(findAdminHub("/login")).toBeNull();
});
});
describe("navItemIsAllowed", () => {
const item = {
href: "/admin/x",
labelKey: "x",
icon: Calendar,
permission: "a.view",
};
it("always allows for super admins", () => {
expect(
navItemIsAllowed(item, { isSuperAdmin: true, has: () => false }),
).toBe(true);
});
it("allows when any needed slug is granted", () => {
expect(
navItemIsAllowed(item, {
isSuperAdmin: false,
has: (s) => s === "a.view",
}),
).toBe(true);
});
it("denies when no slug is granted", () => {
expect(
navItemIsAllowed(item, { isSuperAdmin: false, has: () => false }),
).toBe(false);
});
it("allows items without a permission requirement", () => {
expect(
navItemIsAllowed(
{ href: "/admin/d", labelKey: "d", icon: Calendar },
{ isSuperAdmin: false, has: () => false },
),
).toBe(true);
});
it("supports an array of permissions (any match)", () => {
const multi = {
href: "/admin/m",
labelKey: "m",
icon: Calendar,
permission: ["x.view", "y.view"],
};
expect(
navItemIsAllowed(multi, {
isSuperAdmin: false,
has: (s) => s === "y.view",
}),
).toBe(true);
});
});
describe("collectNavPermissionSlugs", () => {
it("returns unique slugs referenced by the sidebar", () => {
const slugs = collectNavPermissionSlugs();
expect(new Set(slugs).size).toBe(slugs.length);
expect(slugs.length).toBeGreaterThan(0);
});
it("is consistent with the nav groups", () => {
const groupSlugs = new Set<string>();
for (const group of ADMIN_NAV_GROUPS) {
for (const item of group.items) {
if (!item.permission) continue;
const needed = Array.isArray(item.permission)
? item.permission
: [item.permission];
for (const s of needed) groupSlugs.add(s);
}
}
expect([...groupSlugs]).toEqual(collectNavPermissionSlugs());
});
});
+706
View File
@@ -0,0 +1,706 @@
import {
Activity,
AlertTriangle,
BadgeCheck,
Ban,
Calendar,
CalendarDays,
ClipboardList,
Cog,
Compass,
FileText,
Filter,
Gavel,
HelpCircle,
Image,
KeyRound,
Languages,
LayoutDashboard,
ListOrdered,
type LucideIcon,
Megaphone,
Monitor,
Newspaper,
Package,
Palette,
Radio,
Server,
Settings,
Shield,
ShoppingCart,
Sparkles,
Store,
Tags,
Terminal,
Ticket,
Trophy,
Users,
UsersRound,
Volume2,
Vote,
Wifi,
Wrench,
} from "lucide-react";
import { PERMS } from "@/lib/permission-slugs";
export interface AdminHubTab {
href: string;
/** Key under pages.admin.hubs.tabs.* */
labelKey: string;
match?: string[];
/** Optional group id for multi-row tab strips (e.g. Radio primary/tools). */
group?: string;
}
export interface AdminHubDefinition {
id: string;
/** Key under pages.admin.hubs.* for title/subtitle */
titleKey: string;
subtitleKey: string;
icon: LucideIcon;
/** Path prefixes belonging to this hub (for chrome + sidebar active). */
prefixes: string[];
tabs: AdminHubTab[];
}
export interface AdminNavItem {
href: string;
labelKey: string;
icon: LucideIcon;
/**
* ACL slug(s) required to show this item. Any match is enough.
* Omit only for the dashboard (already gated by admin.dashboard).
*/
permission?: string | readonly string[];
/** When set, sidebar item is active if pathname matches any prefix. */
matchPrefixes?: string[];
/** Prefixes that must NOT count as active (e.g. /admin/users vs multi-accounts). */
matchExcludePrefixes?: string[];
}
export interface AdminNavGroup {
labelKey: string;
icon: LucideIcon;
items: AdminNavItem[];
}
/**
* Hubs with real sibling tabs only. Standalone features have no hub chrome
* (sidebar links go straight to the page).
*/
export const ADMIN_HUBS: AdminHubDefinition[] = [
{
id: "events",
titleKey: "events",
subtitleKey: "eventsSubtitle",
icon: Calendar,
prefixes: ["/admin/events"],
tabs: [
{ href: "/admin/events", labelKey: "events", match: ["/admin/events"] },
{ href: "/admin/events/types", labelKey: "eventTypes" },
],
},
{
id: "tickets",
titleKey: "tickets",
subtitleKey: "ticketsSubtitle",
icon: Ticket,
prefixes: ["/admin/tickets", "/admin/help-tickets"],
tabs: [
{
href: "/admin/tickets",
labelKey: "ticketInbox",
match: ["/admin/tickets"],
},
{
href: "/admin/tickets/desk",
labelKey: "tickets",
match: ["/admin/tickets/desk"],
},
{
href: "/admin/help-tickets",
labelKey: "helpTickets",
match: ["/admin/help-tickets"],
},
{ href: "/admin/tickets/templates", labelKey: "templates" },
],
},
{
id: "staff-access",
titleKey: "staffAccess",
subtitleKey: "staffAccessSubtitle",
icon: KeyRound,
prefixes: ["/admin/teams", "/admin/permissions", "/admin/housekeeping"],
tabs: [
{ href: "/admin/teams", labelKey: "teams" },
{
href: "/admin/permissions",
labelKey: "permissions",
match: ["/admin/permissions"],
},
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
],
},
{
id: "moderation",
titleKey: "moderation",
subtitleKey: "moderationSubtitle",
icon: Gavel,
prefixes: ["/admin/moderation"],
tabs: [
{ href: "/admin/moderation", labelKey: "overview" },
{ href: "/admin/moderation/actions", labelKey: "actions" },
{
href: "/admin/moderation/cfh",
labelKey: "cfh",
match: ["/admin/moderation/cfh"],
},
{ href: "/admin/moderation/team", labelKey: "team" },
],
},
{
id: "radio",
titleKey: "radio",
subtitleKey: "radioSubtitle",
icon: Radio,
prefixes: ["/admin/radio"],
tabs: [
{ href: "/admin/radio", labelKey: "overview", group: "primary" },
{ href: "/admin/radio/history", labelKey: "history", group: "primary" },
{
href: "/admin/radio/moderation",
labelKey: "moderationTab",
group: "primary",
},
{
href: "/admin/radio/monitoring",
labelKey: "monitoring",
group: "primary",
},
{
href: "/admin/radio/settings",
labelKey: "settingsTab",
group: "primary",
},
{ href: "/admin/radio/banners", labelKey: "banners", group: "tools" },
{ href: "/admin/radio/embed", labelKey: "embed", group: "tools" },
{ href: "/admin/radio/api-keys", labelKey: "apiKeys", group: "tools" },
{ href: "/admin/radio/points", labelKey: "points", group: "tools" },
{ href: "/admin/radio/ranks", labelKey: "ranks", group: "tools" },
{ href: "/admin/radio/autodj", labelKey: "autoDj", group: "tools" },
],
},
{
id: "analytics",
titleKey: "analytics",
subtitleKey: "analyticsSubtitle",
icon: Activity,
prefixes: ["/admin/analytics"],
tabs: [
{
href: "/admin/analytics",
labelKey: "analytics",
match: ["/admin/analytics"],
},
{ href: "/admin/analytics/activity", labelKey: "activity" },
{ href: "/admin/analytics/economy", labelKey: "economy" },
],
},
{
id: "devops",
titleKey: "devops",
subtitleKey: "devopsSubtitle",
icon: Server,
prefixes: ["/admin/devops"],
tabs: [
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
{ href: "/admin/devops/errors", labelKey: "errors" },
],
},
];
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
export function findAdminHub(pathname: string): AdminHubDefinition | null {
let best: AdminHubDefinition | null = null;
let bestLen = -1;
for (const hub of ADMIN_HUBS) {
for (const prefix of hub.prefixes) {
const hit = pathname === prefix || pathname.startsWith(`${prefix}/`);
if (hit && prefix.length > bestLen) {
best = hub;
bestLen = prefix.length;
}
}
}
return best;
}
/** Feature-first sidebar: one entry per feature; hubs only for sibling tabs. */
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
{
labelKey: "overview",
icon: LayoutDashboard,
items: [
{
href: "/admin",
labelKey: "dashboard",
icon: LayoutDashboard,
permission: PERMS.ADMIN_DASHBOARD,
},
],
},
{
labelKey: "content",
icon: Newspaper,
items: [
{
href: "/admin/articles",
labelKey: "articles",
icon: Newspaper,
permission: PERMS.NEWS_VIEW,
matchPrefixes: ["/admin/articles"],
},
{
href: "/admin/photos",
labelKey: "photos",
icon: Image,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/banners",
labelKey: "banners",
icon: Megaphone,
permission: PERMS.BANNERS_VIEW,
},
{
href: "/admin/ads",
labelKey: "advertisements",
icon: FileText,
permission: PERMS.PAGES_VIEW,
matchPrefixes: ["/admin/ads"],
},
{
href: "/admin/media",
labelKey: "media",
icon: Image,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/navigation",
labelKey: "navigator",
icon: Compass,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/help-questions",
labelKey: "helpCenter",
icon: HelpCircle,
permission: PERMS.PAGES_VIEW,
matchPrefixes: ["/admin/help-questions"],
},
{
href: "/admin/writeable-boxes",
labelKey: "writeableBoxes",
icon: Package,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/tags",
labelKey: "tags",
icon: Tags,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/prefixes",
labelKey: "prefixes",
icon: Sparkles,
permission: PERMS.PREFIXES_VIEW,
},
],
},
{
labelKey: "community",
icon: Calendar,
items: [
{
href: "/admin/events",
labelKey: "events",
icon: Calendar,
permission: PERMS.EVENTS_VIEW,
matchPrefixes: ["/admin/events"],
},
{
href: "/admin/polls",
labelKey: "polls",
icon: Vote,
permission: PERMS.POLLS_VIEW,
matchPrefixes: ["/admin/polls"],
},
{
href: "/admin/guilds",
labelKey: "guilds",
icon: UsersRound,
permission: PERMS.USERS_VIEW,
matchPrefixes: ["/admin/guilds"],
},
{
href: "/admin/tickets",
labelKey: "tickets",
icon: Ticket,
permission: PERMS.TICKETS_VIEW,
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
},
],
},
{
labelKey: "usersAndAccess",
icon: Users,
items: [
{
href: "/admin/users",
labelKey: "users",
icon: Users,
permission: PERMS.USERS_VIEW,
matchPrefixes: ["/admin/users"],
matchExcludePrefixes: ["/admin/users/multi-accounts"],
},
{
href: "/admin/users/multi-accounts",
labelKey: "multiAccounts",
icon: Users,
permission: PERMS.USERS_VIEW,
},
{
href: "/admin/online",
labelKey: "onlineUsers",
icon: Wifi,
permission: PERMS.USERS_VIEW,
},
{
href: "/admin/applications",
labelKey: "applications",
icon: ClipboardList,
permission: PERMS.USERS_VIEW,
},
{
href: "/admin/teams",
labelKey: "staffAccess",
icon: KeyRound,
permission: [
PERMS.USERS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.SETTINGS_VIEW,
],
matchPrefixes: [
"/admin/teams",
"/admin/permissions",
"/admin/housekeeping",
],
},
{
href: "/admin/bans",
labelKey: "bans",
icon: Ban,
permission: PERMS.BANS_VIEW,
},
{
href: "/admin/ip",
labelKey: "ipManagement",
icon: Shield,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/vpn",
labelKey: "vpn",
icon: Shield,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/wordfilter",
labelKey: "wordFilter",
icon: Filter,
permission: PERMS.WORDFILTER_VIEW,
},
{
href: "/admin/moderation",
labelKey: "moderation",
icon: Gavel,
permission: PERMS.MODERATION_VIEW,
matchPrefixes: ["/admin/moderation"],
},
],
},
{
labelKey: "economy",
icon: ShoppingCart,
items: [
{
href: "/admin/catalog",
labelKey: "catalog",
icon: Store,
permission: PERMS.CATALOG_VIEW,
matchPrefixes: ["/admin/catalog"],
},
{
href: "/admin/catalog/maintenance",
labelKey: "catalogMaintenance",
icon: Wrench,
permission: PERMS.CATALOG_EDIT,
matchPrefixes: ["/admin/catalog/maintenance"],
},
{
href: "/admin/items",
labelKey: "itemsBase",
icon: Package,
permission: PERMS.CATALOG_VIEW,
matchPrefixes: ["/admin/items"],
},
{
href: "/admin/rare-values",
labelKey: "rareValues",
icon: Sparkles,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/badges",
labelKey: "badges",
icon: BadgeCheck,
permission: PERMS.CATALOG_VIEW,
},
{
href: "/admin/achievements",
labelKey: "achievements",
icon: Trophy,
permission: PERMS.CATALOG_VIEW,
},
{
href: "/admin/sounds",
labelKey: "sounds",
icon: Volume2,
permission: PERMS.CATALOG_VIEW,
},
{
href: "/admin/shop",
labelKey: "shop",
icon: ShoppingCart,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/marketplace",
labelKey: "marketplace",
icon: Store,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/transactions",
labelKey: "transactions",
icon: Activity,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/vouchers",
labelKey: "vouchers",
icon: Ticket,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/subscriptions",
labelKey: "subscriptions",
icon: ClipboardList,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/calendar",
labelKey: "calendar",
icon: CalendarDays,
permission: PERMS.SHOP_VIEW,
matchPrefixes: ["/admin/calendar"],
},
],
},
{
labelKey: "radio",
icon: Radio,
items: [
{
href: "/admin/radio",
labelKey: "radio",
icon: Radio,
permission: PERMS.RADIO_VIEW,
matchPrefixes: ["/admin/radio"],
},
],
},
{
labelKey: "system",
icon: Settings,
items: [
{
href: "/admin/settings",
labelKey: "settings",
icon: Cog,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/menu",
labelKey: "adminMenu",
icon: ListOrdered,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/theme",
labelKey: "theme",
icon: Sparkles,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/maintenance",
labelKey: "maintenance",
icon: Wrench,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/favicon",
labelKey: "favicon",
icon: Image,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/emulator",
labelKey: "emulator",
icon: Server,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/email-templates",
labelKey: "emailTemplates",
icon: FileText,
permission: PERMS.PAGES_VIEW,
},
],
},
{
labelKey: "tools",
icon: Wrench,
items: [
{
href: "/admin/commandocentrum",
labelKey: "commandocentrum",
icon: Terminal,
permission: PERMS.RCON_EXECUTE,
},
{
href: "/admin/rooms",
labelKey: "rooms",
icon: Store,
permission: PERMS.ROOMS_VIEW,
matchPrefixes: ["/admin/rooms"],
},
{
href: "/admin/studio",
labelKey: "studio",
icon: Palette,
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/studio"],
},
{
href: "/admin/translations",
labelKey: "translations",
icon: Languages,
permission: PERMS.SETTINGS_VIEW,
matchPrefixes: ["/admin/translations"],
},
],
},
{
labelKey: "monitoring",
icon: Monitor,
items: [
{
href: "/admin/logs",
labelKey: "logs",
icon: FileText,
permission: PERMS.LOGS_VIEW,
matchPrefixes: ["/admin/logs"],
matchExcludePrefixes: [
"/admin/logs/audit",
"/admin/logs/chat",
"/admin/logs/commands",
"/admin/logs/trades",
],
},
{
href: "/admin/logs/audit",
labelKey: "auditLog",
icon: ClipboardList,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/logs/chat",
labelKey: "chatLog",
icon: FileText,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/logs/commands",
labelKey: "commandLog",
icon: Terminal,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/logs/trades",
labelKey: "tradeLog",
icon: Activity,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/analytics",
labelKey: "analytics",
icon: Activity,
permission: PERMS.ANALYTICS_VIEW,
matchPrefixes: ["/admin/analytics"],
},
{
href: "/admin/devops",
labelKey: "devops",
icon: Server,
permission: PERMS.DEVOPS_VIEW,
matchPrefixes: ["/admin/devops"],
},
{
href: "/admin/alerts",
labelKey: "alerts",
icon: AlertTriangle,
permission: PERMS.NOTIFICATIONS_VIEW,
},
],
},
];
/** Whether a nav item should be visible for the given permission set. */
export function navItemIsAllowed(
item: AdminNavItem,
opts: { isSuperAdmin: boolean; has: (slug: string) => boolean },
): boolean {
if (opts.isSuperAdmin) return true;
if (!item.permission) return true;
const needed = Array.isArray(item.permission)
? item.permission
: [item.permission];
return needed.some((slug) => opts.has(slug));
}
/** Collect every ACL slug referenced by the sidebar (for layout gating). */
export function collectNavPermissionSlugs(): string[] {
const slugs = new Set<string>();
for (const group of ADMIN_NAV_GROUPS) {
for (const item of group.items) {
if (!item.permission) continue;
const needed = Array.isArray(item.permission)
? item.permission
: [item.permission];
for (const slug of needed) slugs.add(slug);
}
}
return [...slugs];
}
+106 -3
View File
@@ -2,16 +2,78 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROUTES: Array<[string, string]> = [
["moderation", "PERMS.MODERATION_VIEW"],
["moderation/actions", "PERMS.MODERATION_EDIT"],
["moderation/cfh", "PERMS.MODERATION_VIEW"],
["logs/audit", "PERMS.LOGS_VIEW"],
["analytics", "PERMS.ANALYTICS_VIEW"],
["devops", "PERMS.DEVOPS_VIEW"],
["online", "PERMS.USERS_VIEW"],
["commandocentrum", "PERMS.RCON_EXECUTE"],
["users/edit/[id]", "PERMS.USERS_EDIT"],
["settings", "PERMS.SETTINGS_VIEW"],
["theme", "PERMS.SETTINGS_VIEW"],
["emulator", "PERMS.SETTINGS_VIEW"],
["bans", "PERMS.BANS_VIEW"],
["wordfilter", "PERMS.WORDFILTER_VIEW"],
["articles", "PERMS.NEWS_VIEW"],
["shop", "PERMS.SHOP_VIEW"],
["transactions", "PERMS.SHOP_VIEW"],
["vouchers", "PERMS.SHOP_VIEW"],
["marketplace", "PERMS.SHOP_VIEW"],
["vpn", "PERMS.SETTINGS_VIEW"],
["ip", "PERMS.SETTINGS_VIEW"],
["maintenance", "PERMS.SETTINGS_VIEW"],
["alerts", "PERMS.NOTIFICATIONS_VIEW"],
["tags", "PERMS.PAGES_VIEW"],
["ads", "PERMS.PAGES_VIEW"],
["media", "PERMS.PAGES_VIEW"],
["photos", "PERMS.PAGES_VIEW"],
["badges", "PERMS.CATALOG_VIEW"],
["teams", "PERMS.USERS_VIEW"],
["applications", "PERMS.USERS_VIEW"],
["guilds", "PERMS.USERS_VIEW"],
["tickets", "PERMS.TICKETS_VIEW"],
["help-tickets", "PERMS.TICKETS_VIEW"],
["permissions", "PERMS.PERMISSIONS_MANAGE"],
["housekeeping", "PERMS.SETTINGS_VIEW"],
["logs", "PERMS.LOGS_VIEW"],
["catalog", "PERMS.CATALOG_VIEW"],
["items", "PERMS.CATALOG_VIEW"],
["items/[id]", "PERMS.CATALOG_VIEW"],
["rooms/edit/[id]", "PERMS.ROOMS_EDIT"],
];
const MOD_ROUTES: Array<[string, string]> = [
["", "requireMod"],
["cfh", "PERMS.MOD_CFH_VIEW"],
["actions", "PERMS.MOD_ACTIONS"],
["bans", "PERMS.MOD_BANS_VIEW"],
["tickets", "PERMS.MOD_TICKETS_VIEW"],
["help-tickets", "PERMS.MOD_TICKETS_VIEW"],
["users", "PERMS.MOD_USERS_VIEW"],
["team", "PERMS.MOD_TEAM_VIEW"],
];
const ACTION_GATES: Array<[string, string]> = [
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
["admin-emulator.ts", "PERMS.SETTINGS_EDIT"],
["admin-bans.ts", "PERMS.USERS_BAN"],
["admin-wordfilter.ts", "PERMS.WORDFILTER_EDIT"],
["admin-articles.ts", "PERMS.NEWS_EDIT"],
["admin-shop.ts", "PERMS.SHOP_EDIT"],
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
["catalog.ts", "PERMS.CATALOG_EDIT"],
["items-base.ts", "PERMS.CATALOG_EDIT"],
["rooms.ts", "PERMS.ROOMS_EDIT"],
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
["admin-ads.ts", "PERMS.PAGES_EDIT"],
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
["translations.ts", "PERMS.SETTINGS_EDIT"],
["tickets.ts", "PERMS.TICKETS_EDIT"],
["admin-help-tickets.ts", "PERMS.TICKETS_EDIT"],
["permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
@@ -20,7 +82,32 @@ const ACTION_GATES: Array<[string, string]> = [
["moderation.ts", "PERMS.MODERATION_EDIT"],
];
describe("administration backend contract", () => {
describe("admin operations route contract", () => {
it.each(ROUTES)("provides and guards /admin/%s", (route, permission) => {
const path = `src/app/admin/${route}/page.tsx`;
expect(existsSync(path), path).toBe(true);
expect(readFileSync(path, "utf8"), path).toContain(permission);
});
it.each(MOD_ROUTES)("provides and guards /mod/%s", (route, permission) => {
const path =
route === "" ? "src/app/mod/page.tsx" : `src/app/mod/${route}/page.tsx`;
expect(existsSync(path), path).toBe(true);
const source = readFileSync(path, "utf8");
const layout = readFileSync("src/app/mod/layout.tsx", "utf8");
if (permission === "requireMod") {
expect(layout).toContain("requireMod");
} else {
expect(source).toContain(permission);
}
});
it("guards radio section via layout", () => {
const path = "src/app/admin/radio/layout.tsx";
expect(existsSync(path), path).toBe(true);
expect(readFileSync(path, "utf8"), path).toContain("PERMS.RADIO_VIEW");
});
it.each([
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
["devops/health", "PERMS.DEVOPS_VIEW"],
@@ -38,7 +125,7 @@ describe("administration backend contract", () => {
expect(source).not.toMatch(/await requireStaffRateLimited\(\)/);
});
it("requires explicit permissions for every administration action", () => {
it("has no requireStaff left in admin action modules", () => {
const dir = "src/actions";
const offenders: string[] = [];
for (const name of readdirSync(dir)) {
@@ -51,8 +138,24 @@ describe("administration backend contract", () => {
expect(offenders).toEqual([]);
});
it("keeps the shared ops health probe behind the API", () => {
it("caches analytics full-scans via redisCache", () => {
for (const path of [
"src/app/admin/analytics/page.tsx",
"src/app/admin/analytics/activity/page.tsx",
"src/app/admin/analytics/economy/page.tsx",
]) {
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
}
});
it("shares ops health probe across CC / DevOps / API", () => {
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
expect(
readFileSync("src/app/admin/commandocentrum/page.tsx", "utf8"),
).toContain("fetchOpsHealth");
expect(readFileSync("src/app/admin/devops/page.tsx", "utf8")).toContain(
"fetchOpsHealth",
);
expect(
readFileSync("src/app/api/admin/devops/health/route.ts", "utf8"),
).toContain("fetchOpsHealth");
+19 -7
View File
@@ -3,16 +3,31 @@ import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOTS = [
"src/app/admin",
"src/components/admin",
"src/app/ase",
"src/app/admin-next",
"src/features/housekeeping",
];
const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
"src/components/admin/catalog/items-shop-preview.tsx",
"src/components/admin/media-grid.tsx",
];
const DATA_COLOR_ALLOWLIST: readonly string[] = [];
const DATA_COLOR_ALLOWLIST = [
"src/app/admin/alerts/page.tsx",
"src/app/admin/banners/banners-manager.tsx",
"src/app/admin/events/events-table.tsx",
"src/app/admin/events/types/event-types-manager.tsx",
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/help-questions/new/page.tsx",
"src/app/admin/help-questions/[id]/page.tsx",
"src/app/admin/prefixes/prefixes-client.tsx",
"src/app/admin/tags/page.tsx",
"src/app/admin/teams/page.tsx",
"src/app/admin/theme/page.tsx",
];
const PUBLIC_STRUCTURAL_TOKEN =
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
@@ -76,11 +91,8 @@ describe("admin theme source audit", () => {
expect(violations).toEqual([]);
});
it("keeps retained Studio workflows on semantic status and overlay colors", () => {
const studioFiles = [
"src/features/housekeeping/domains/hotel/pages/studio.tsx",
];
const violations = studioFiles.flatMap((file) => {
it("keeps every import workflow on semantic admin status and overlay colors", () => {
const violations = sourceFiles("src/app/admin/import").flatMap((file) => {
const source = readFileSync(file, "utf8");
const risky =
source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ?? [];
+6 -17
View File
@@ -3,23 +3,12 @@ import { describe, expect, it } from "vitest";
describe("ACL management contract", () => {
it("uses normalized ACL persistence and the permissions.manage guard", () => {
const wrapper = readFileSync("src/actions/permissions.ts", "utf8");
const service = readFileSync(
"src/features/housekeeping/domains/system/services/mutations.ts",
"utf8",
);
expect(wrapper).toContain("PERMS.PERMISSIONS_MANAGE");
expect(wrapper).toContain("adminAction");
expect(wrapper).toContain("access.permissions.update");
expect(wrapper).toContain("access.permissions.repair");
expect(service).toContain('import "server-only"');
expect(service).toContain("AclModelPermission");
expect(service).not.toContain(
"export const systemProductionMutationAdapter",
);
expect(service).not.toContain("legacyMessage");
expect(service).not.toContain("websiteHousekeepingPermissions");
expect(service).not.toContain("websiteTeams");
const source = readFileSync("src/actions/permissions.ts", "utf8");
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
expect(source).toContain("adminAction");
expect(source).toContain("AclModelPermission");
expect(source).not.toContain("websiteHousekeepingPermissions");
expect(source).not.toContain("websiteTeams");
});
it("ships an idempotent ACL completion migration", () => {
-343
View File
@@ -1,343 +0,0 @@
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
export type FieldType =
| "text"
| "password"
| "url"
| "number"
| "boolean"
| "textarea"
| "select";
export type SettingsGroupIcon =
| "building"
| "image"
| "gamepad"
| "music"
| "shield"
| "link"
| "user-plus";
export interface ManagedField {
key: string;
label: string;
description?: string;
type?: FieldType;
placeholder?: string;
defaultValue?: string;
options?: Array<{ value: string; label: string }>;
}
export interface SettingsGroup {
id: string;
title: string;
icon: SettingsGroupIcon;
description?: string;
fields: ManagedField[];
}
/** Keys managed by the structured CMS Settings form (not theme / VPN / maintenance pages). */
export const SETTINGS_GROUPS: SettingsGroup[] = [
{
id: "identity",
title: "Hotel identity",
icon: "building",
description: "Name, branding and defaults shown across the site.",
fields: [
{
key: "cms_logo",
label: "Logo URL",
description: "Header logo path or absolute URL.",
type: "url",
placeholder: "/api/media/logo/…",
},
{
key: "cms_favicon",
label: "Favicon URL",
type: "url",
placeholder: "/favicon.svg",
},
{
key: "cms_header",
label: "Header background",
type: "url",
placeholder: "/assets/images/background.png",
defaultValue: "/assets/images/background.png",
},
{
key: "default_dark",
label: "Dark mode by default",
description: "New visitors start in dark mode unless they override it.",
type: "boolean",
defaultValue: "0",
},
{
key: "min_staff_rank",
label: "Minimum staff rank",
description:
"Minimum rank treated as staff (admin lists and public staff page).",
type: "number",
defaultValue: "7",
},
],
},
{
id: "imaging",
title: "Avatars & badges",
icon: "image",
description: "Imaging endpoints used for avatars and badge icons.",
fields: [
{
key: "habbo_imaging_url",
label: "Public imager URL",
type: "url",
defaultValue: "/imaging",
description:
"Public avatar endpoint (relative or absolute). Leave as /imaging to serve from this site.",
},
{
key: "imaging_use_habbo_fallback",
label: "Use Habbo.com as avatar fallback (legacy proxy)",
type: "boolean",
defaultValue: "1",
},
{
key: "badge_base_url",
label: "Badge icons base URL",
type: "url",
placeholder: "/swf/c_images/album1584",
},
{
key: "badges_path",
label: "Badges path (rares page)",
type: "url",
},
{
key: "furniture_icons_path",
label: "Furniture icons path",
type: "url",
},
],
},
{
id: "client",
title: "Nitro client",
icon: "gamepad",
description: "Game client loaded at /client.",
fields: [
{
key: "nitro_client_url",
label: "Nitro client URL",
description: "Absolute or same-origin URL for the Nitro iframe.",
type: "url",
placeholder: "https://…/client/",
},
{
key: "nitro_files_root",
label: "Nitro-Files root (server path)",
description:
"Filesystem root used when importing furni / writing live assets.",
type: "text",
placeholder: "E:\\path\\to\\Nitro-Files",
},
{
key: "gamedata_root",
label: "Production Gamedata root (server path)",
description:
"Filesystem root served at /gamedata. Auto-detected as /var/www/Gamedata when present.",
type: "text",
placeholder: "/var/www/Gamedata",
},
],
},
{
id: "assets",
title: "Game assets",
icon: "music",
description: "Public URLs and overrides for SWF / Nitro asset packages.",
fields: [
{
key: "furnidata_translate_enabled",
label: "Translate furniture names",
description:
"Rebuild FurnitureData_<lang>.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.",
type: "boolean",
defaultValue: "1",
},
{
key: "habbo_gamedata_hotel",
label: "Habbo hotel for furni names",
description:
"Official Habbo locale used for catalog name suggestions, furni import enrichment, and badge text lookup (furnidata / external texts).",
type: "select",
defaultValue: "it",
options: HABBO_GAMEDATA_HOTELS.map((h) => ({
value: h.value,
label: h.label,
})),
},
{
key: "soundtrack_base_url",
label: "Song disks MP3 base URL",
type: "url",
defaultValue: "/swf/dcr/hof_furni/mp3/",
},
{
key: "furni_data_mirror_path",
label: "FurnitureData mirror path",
type: "text",
},
{
key: "figure_swf_base_url",
label: "Figure SWF base URL (override)",
type: "url",
},
{
key: "effect_swf_base_url",
label: "Effect SWF base URL (override)",
type: "url",
},
{
key: "pet_swf_base_url",
label: "Pet SWF base URL (override)",
type: "url",
},
],
},
{
id: "radio",
title: "Radio",
icon: "music",
description: "Public radio player and DJ monitoring feeds.",
fields: [
{
key: "radio_enabled",
label: "Enable radio player",
type: "boolean",
defaultValue: "0",
},
{
key: "radio_name",
label: "Radio display name",
type: "text",
},
{
key: "radio_stream_url",
label: "Stream URL",
type: "url",
},
{
key: "radio_now_playing_api_url",
label: "Now-playing API URL",
type: "url",
},
{
key: "radio_listeners_api_url",
label: "Listeners API URL",
type: "url",
},
],
},
{
id: "security",
title: "Security & registration",
icon: "shield",
description: "Account limits, captcha and staff 2FA.",
fields: [
{
key: "force_staff_2fa",
label: "Require 2FA for staff",
description: "Staff without 2FA are redirected to set it up.",
type: "boolean",
defaultValue: "0",
},
{
key: "require_email_verification",
label: "Require email verification",
description:
"Block login until the account email is verified (accounts without email are unaffected).",
type: "boolean",
defaultValue: "0",
},
{
key: "max_accounts_per_ip",
label: "Max accounts per IP",
description: "0 = unlimited.",
type: "number",
defaultValue: "0",
},
{
key: "captcha_provider",
label: "Captcha provider",
description: "none | turnstile | recaptcha | hcaptcha",
type: "text",
defaultValue: "none",
},
{
key: "turnstile_site_key",
label: "Turnstile site key",
type: "text",
},
{
key: "hcaptcha_site_key",
label: "hCaptcha site key",
type: "text",
},
{
key: "recaptcha_site_key",
label: "reCAPTCHA site key",
type: "text",
},
{
key: "abuse_guard_enabled",
label: "Enable abuse guard",
type: "boolean",
defaultValue: "0",
},
{
key: "abuse_guard_threshold",
label: "Abuse guard threshold",
type: "number",
defaultValue: "200",
},
{
key: "abuse_guard_window_seconds",
label: "Abuse guard window (seconds)",
type: "number",
defaultValue: "10",
},
],
},
{
id: "misc",
title: "Other",
icon: "link",
description: "Extra hotel features.",
fields: [
{
key: "drawbadge.price",
label: "Draw-badge price",
type: "number",
defaultValue: "0",
},
],
},
];
export const MANAGED_SETTING_KEYS: string[] = SETTINGS_GROUPS.flatMap((g) =>
g.fields.map((f) => f.key),
);
export const BOOLEAN_KEYS = new Set(
SETTINGS_GROUPS.flatMap((g) =>
g.fields.filter((f) => f.type === "boolean").map((f) => f.key),
),
);
export const FIELD_DEFAULTS: Record<string, string> = Object.fromEntries(
SETTINGS_GROUPS.flatMap((g) =>
g.fields
.filter((f) => f.defaultValue != null)
.map((f) => [f.key, f.defaultValue as string]),
),
);
+4 -23
View File
@@ -1,14 +1,9 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "@/features/housekeeping/foundation/contracts";
import { redirectSafe } from "@/lib/foundation/security";
import { canAccess, getAdminContext } from "@/lib/permissions";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import {
requireHousekeepingCapability,
requireMod,
requireModPermission,
requirePermission,
@@ -75,23 +70,6 @@ describe("requirePermission", () => {
});
});
describe("requireHousekeepingCapability", () => {
it("reuses an already-created capability context without reloading permissions", async () => {
const context: HousekeepingCapabilityContext = {
actor: { id: 1, username: "admin", rank: 0 },
isSuperAdmin: false,
has: (slug) => slug === "admin.users.view",
hasAny: (...slugs) => slugs.includes("admin.users.view"),
hasAll: (...slugs) => slugs.every((slug) => slug === "admin.users.view"),
};
await expect(
requireHousekeepingCapability(anyCapability("admin.users.view"), context),
).resolves.toBe(context);
expect(getAdminContext).not.toHaveBeenCalled();
});
});
describe("requireMod", () => {
it("allows with MOD_DASHBOARD", async () => {
vi.mocked(getAdminContext).mockResolvedValue({
@@ -139,6 +117,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(
"/admin?error=ratelimit",
"/admin",
);
});
});
+5 -23
View File
@@ -1,9 +1,3 @@
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "@/features/housekeeping/foundation/contracts";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { redirectSafe } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { clientIp, rateLimit } from "@/lib/rate-limit";
@@ -13,18 +7,6 @@ export interface StaffUser {
rank: number;
username: string;
}
export async function requireHousekeepingCapability(
requirement: CapabilityRequirement,
context?: HousekeepingCapabilityContext,
): Promise<HousekeepingCapabilityContext> {
const capabilityContext =
context ?? (await getHousekeepingCapabilityContext());
const authorization = authorizeHousekeeping(capabilityContext, requirement);
if (!authorization.ok) redirectSafe("/ase", "/ase");
return capabilityContext;
}
export async function requireStaff(): Promise<StaffUser> {
const { session, permissions } = await getAdminContext();
@@ -48,7 +30,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("/admin", "/admin");
return {
id: session.user.id,
rank: session.user.rank,
@@ -62,7 +44,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("/admin?error=ratelimit", "/admin");
return staff;
}
@@ -70,13 +52,13 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
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("/admin?error=ratelimit", "/admin");
return staff;
}
/**
* Mod-lite gate: needs any mod.* / moderation ACL, not admin.dashboard.
* Retained for capability-compatible action adapters used by mid-rank staff.
* Use for `/mod` layout so mid-ranks can access without full housekeeping.
*/
export async function requireMod(): Promise<StaffUser> {
const { session, permissions } = await getAdminContext();
@@ -106,6 +88,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("/mod", "/mod");
return staff;
}
+33 -54
View File
@@ -1,40 +1,41 @@
import "server-only";
import { count, desc, eq } from "drizzle-orm";
import type { OnlineUser } from "@/components/admin/dashboard";
import { db, User } from "@/lib/db";
interface OpsOnlineUserRow {
readonly id: number;
readonly username: string;
readonly look: string;
readonly lastOnline: number;
}
/** Shared online roster used by CommandoCentrum (and linked from other ops hubs). */
export async function fetchOpsOnlineUsers(
limit = 40,
): Promise<{ count: number; users: OnlineUser[] }> {
const [countRow, onlineUsersRaw] = await Promise.all([
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({
id: User.id,
username: User.username,
look: User.look,
lastOnline: User.lastOnline,
})
.from(User)
.where(eq(User.online, "1"))
.orderBy(desc(User.lastOnline))
.limit(limit)
.then((rows) => rows)
.catch(
() =>
[] as Array<{
id: number;
username: string;
look: string;
lastOnline: number;
}>,
),
]);
function loadOnlineCount(): Promise<number> {
return db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0);
}
function loadOnlineRows(limit: number): Promise<OpsOnlineUserRow[]> {
return db
.select({
id: User.id,
username: User.username,
look: User.look,
lastOnline: User.lastOnline,
})
.from(User)
.where(eq(User.online, "1"))
.orderBy(desc(User.lastOnline))
.limit(limit)
.then((rows) => rows);
}
function onlineRoster(countRow: number, onlineUsersRaw: OpsOnlineUserRow[]) {
const users: OnlineUser[] = onlineUsersRaw.map((u) => ({
id: u.id,
username: u.username,
@@ -46,25 +47,3 @@ function onlineRoster(countRow: number, onlineUsersRaw: OpsOnlineUserRow[]) {
return { count: countRow, users };
}
/** Fail-aware roster for guarded System workflows. */
export async function fetchOpsOnlineUsersStrict(
limit = 40,
): Promise<{ count: number; users: OnlineUser[] }> {
const [countRow, onlineUsersRaw] = await Promise.all([
loadOnlineCount(),
loadOnlineRows(limit),
]);
return onlineRoster(countRow, onlineUsersRaw);
}
/** Shared tolerant roster used by the legacy CommandoCentrum. */
export async function fetchOpsOnlineUsers(
limit = 40,
): Promise<{ count: number; users: OnlineUser[] }> {
const [countRow, onlineUsersRaw] = await Promise.all([
loadOnlineCount().catch(() => 0),
loadOnlineRows(limit).catch(() => []),
]);
return onlineRoster(countRow, onlineUsersRaw);
}
+7 -214
View File
@@ -10,7 +10,6 @@ import {
ne,
or,
type SQL,
sql,
} from "drizzle-orm";
import { alias } from "drizzle-orm/mysql-core";
import { calcPagination } from "@/lib/admin-helpers";
@@ -37,16 +36,11 @@ export interface TicketInboxRow {
export interface FetchTicketInboxOptions {
page: number;
perPage: number;
offset?: number;
search?: string;
type?: TicketInboxTypeFilter;
/** Default true: only non-closed CMS + open help-center. */
openOnly?: boolean;
base?: "admin" | "mod";
sort?: "id" | "subject" | "status" | "updatedAt";
order?: "asc" | "desc";
/** Fail-closed, DB-paged boundary for security-sensitive read models. */
strict?: boolean;
}
function toSortMs(value: Date | string | null | undefined): number {
@@ -60,6 +54,7 @@ async function fetchCmsCandidates(
limit: number,
search: string,
openOnly: boolean,
base: "admin" | "mod",
): Promise<TicketInboxRow[]> {
const Creator = alias(User, "inbox_ticket_creator");
const conditions: SQL[] = [];
@@ -101,7 +96,7 @@ async function fetchCmsCandidates(
.limit(limit)
.catch(() => []);
const prefix = "/ase/people/support/tickets";
const prefix = base === "mod" ? "/mod/tickets" : "/admin/tickets";
return rows.map((row) => ({
key: `cms-${row.id}`,
@@ -122,6 +117,7 @@ async function fetchHelpCandidates(
limit: number,
search: string,
openOnly: boolean,
base: "admin" | "mod",
): Promise<TicketInboxRow[]> {
const conditions: SQL[] = [];
@@ -190,7 +186,7 @@ async function fetchHelpCandidates(
: [];
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const prefix = "/ase/people/support/help-tickets";
const prefix = base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets";
return rows.map((row) => ({
key: `help-${row.id}`,
@@ -271,209 +267,6 @@ async function countHelp(search: string, openOnly: boolean): Promise<number> {
return Number(rows[0]?.total ?? 0);
}
function strictRows<T>(result: unknown): T[] {
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid ticket inbox driver result");
}
return result[0] as T[];
}
async function fetchStrictUnifiedTicketInbox(
options: FetchTicketInboxOptions,
): Promise<{
rows: TicketInboxRow[];
total: number;
page: number;
perPage: number;
lastPage: number;
cmsTotal: number;
helpTotal: number;
}> {
const type = options.type ?? "all";
const openOnly = options.openOnly !== false;
const search = options.search?.trim() ?? "";
const pattern = `%${search}%`;
const numericCandidate = /^\d+$/.test(search) ? Number(search) : null;
const numericSearch =
numericCandidate !== null &&
Number.isSafeInteger(numericCandidate) &&
numericCandidate > 0
? numericCandidate
: null;
if (
!Number.isFinite(options.perPage) ||
!Number.isFinite(options.page) ||
(options.offset !== undefined && !Number.isFinite(options.offset))
) {
throw new Error("invalid strict ticket inbox pagination");
}
const perPage = Math.min(Math.max(Math.trunc(options.perPage), 1), 100);
const requestedPage = Math.max(Math.trunc(options.page), 1);
const offset = Math.min(
Math.max(Math.trunc(options.offset ?? (requestedPage - 1) * perPage), 0),
10_000,
);
const page = Math.floor(offset / perPage) + 1;
const cmsConditions: SQL[] = [];
if (openOnly) cmsConditions.push(sql`t.status <> 'closed'`);
if (search) {
cmsConditions.push(sql`(
t.subject LIKE ${pattern}
OR t.category LIKE ${pattern}
OR creator.username LIKE ${pattern}
${numericSearch !== null ? sql`OR t.id = ${numericSearch}` : sql``}
)`);
}
const helpConditions: SQL[] = [];
if (openOnly) helpConditions.push(sql`h.open = 1`);
if (search) {
helpConditions.push(sql`(
h.title LIKE ${pattern}
OR h.content LIKE ${pattern}
OR help_user.username LIKE ${pattern}
${numericSearch !== null ? sql`OR h.id = ${numericSearch}` : sql``}
)`);
}
const cmsWhere =
cmsConditions.length > 0
? sql`WHERE ${sql.join(cmsConditions, sql` AND `)}`
: sql``;
const helpWhere =
helpConditions.length > 0
? sql`WHERE ${sql.join(helpConditions, sql` AND `)}`
: sql``;
const cmsSelect = sql`
SELECT 'cms' AS kind, t.id AS numericId, t.subject AS title,
COALESCE(creator.username, '—') AS user, t.creator_id AS userId,
t.status AS status, (t.status <> 'closed') AS open,
COALESCE(UNIX_TIMESTAMP(t.updated_at), UNIX_TIMESTAMP(t.created_at), 0) * 1000 AS sortAt,
COALESCE(t.updated_at, t.created_at) AS dateValue
FROM website_tickets t
LEFT JOIN users creator ON creator.id = t.creator_id
${cmsWhere}
`;
const helpSelect = sql`
SELECT 'help' AS kind, h.id AS numericId, h.title AS title,
COALESCE(help_user.username, '—') AS user, h.user_id AS userId,
IF(h.open = 1, 'open', 'closed') AS status, h.open AS open,
COALESCE(UNIX_TIMESTAMP(h.updated_at), UNIX_TIMESTAMP(h.created_at), 0) * 1000 AS sortAt,
COALESCE(h.updated_at, h.created_at) AS dateValue
FROM website_help_center_tickets h
LEFT JOIN users help_user ON help_user.id = h.user_id
${helpWhere}
`;
const unified =
type === "cms"
? cmsSelect
: type === "help"
? helpSelect
: sql`${cmsSelect} UNION ALL ${helpSelect}`;
const sortColumn =
options.sort === "id"
? sql`ticket_rows.numericId`
: options.sort === "subject"
? sql`ticket_rows.title`
: options.sort === "status"
? sql`ticket_rows.status`
: sql`ticket_rows.sortAt`;
const direction = options.order === "asc" ? sql`ASC` : sql`DESC`;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT * FROM (${unified}) AS ticket_rows
ORDER BY ${sortColumn} ${direction}, ticket_rows.kind ASC,
ticket_rows.numericId ${direction}
LIMIT ${perPage} OFFSET ${offset}
`),
db.execute(sql`
SELECT COUNT(*) AS total,
SUM(ticket_rows.kind = 'cms') AS cmsTotal,
SUM(ticket_rows.kind = 'help') AS helpTotal
FROM (${unified}) AS ticket_rows
`),
]);
const counts = strictRows<{
total: number | bigint;
cmsTotal: number | bigint | null;
helpTotal: number | bigint | null;
}>(countResult)[0];
if (!counts) throw new Error("ticket inbox counts unavailable");
const total = Number(counts.total);
const cmsTotal = Number(counts.cmsTotal ?? 0);
const helpTotal = Number(counts.helpTotal ?? 0);
if (
!Number.isSafeInteger(total) ||
total < 0 ||
!Number.isSafeInteger(cmsTotal) ||
cmsTotal < 0 ||
!Number.isSafeInteger(helpTotal) ||
helpTotal < 0
) {
throw new Error("invalid ticket inbox counts");
}
const rows = strictRows<{
kind: TicketInboxKind;
numericId: number | bigint;
title: string;
user: string;
userId: number | null;
status: string;
open: number | boolean;
sortAt: number | bigint;
dateValue: Date | string | null;
}>(rowsResult).map((row) => {
const id = Number(row.numericId);
const sortAt = Number(row.sortAt);
if (
(row.kind !== "cms" && row.kind !== "help") ||
!Number.isSafeInteger(id) ||
id <= 0 ||
!Number.isSafeInteger(sortAt) ||
sortAt < 0 ||
typeof row.title !== "string" ||
typeof row.user !== "string" ||
typeof row.status !== "string"
) {
throw new Error("invalid ticket inbox row");
}
const prefix =
row.kind === "cms"
? "/ase/people/support/tickets"
: "/ase/people/support/help-tickets";
const dateValue =
row.dateValue === null
? null
: row.dateValue instanceof Date
? row.dateValue
: new Date(row.dateValue);
if (dateValue !== null && !Number.isFinite(dateValue.getTime())) {
throw new Error("invalid ticket inbox date");
}
return {
key: `${row.kind}-${id}`,
kind: row.kind,
id: String(id),
title: row.title,
user: row.user,
userId: row.userId === null ? null : Number(row.userId),
status: row.status,
open: Boolean(row.open),
sortAt,
date: formatDate(dateValue, "date"),
href: `${prefix}/${id}`,
};
});
return {
rows,
total,
page,
perPage,
lastPage: Math.max(1, Math.ceil(total / perPage)),
cmsTotal,
helpTotal,
};
}
/** Merged read-model over CMS desk + help-center queues (no DB merge). */
export async function fetchUnifiedTicketInbox(
options: FetchTicketInboxOptions,
@@ -486,10 +279,10 @@ export async function fetchUnifiedTicketInbox(
cmsTotal: number;
helpTotal: number;
}> {
if (options.strict) return fetchStrictUnifiedTicketInbox(options);
const type = options.type ?? "all";
const openOnly = options.openOnly !== false;
const search = options.search?.trim() ?? "";
const base = options.base ?? "admin";
const includeCms = type === "all" || type === "cms";
const includeHelp = type === "all" || type === "help";
@@ -505,10 +298,10 @@ export async function fetchUnifiedTicketInbox(
const [cmsRows, helpRows] = await Promise.all([
includeCms
? fetchCmsCandidates(window, search, openOnly)
? fetchCmsCandidates(window, search, openOnly, base)
: Promise.resolve([]),
includeHelp
? fetchHelpCandidates(window, search, openOnly)
? fetchHelpCandidates(window, search, openOnly, base)
: Promise.resolve([]),
]);
+13 -23
View File
@@ -4,33 +4,23 @@ import { count, eq, ne } from "drizzle-orm";
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
export async function fetchTicketQueueOpenCounts(options?: {
readonly strict?: boolean;
}): Promise<{
export async function fetchTicketQueueOpenCounts(): Promise<{
cmsOpen: number;
helpOpen: number;
}> {
const cms = db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed"));
const help = db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true));
const readCount = async (
query: typeof cms | typeof help,
): Promise<number> => {
const rows = await query;
const value = Number(rows[0]?.total);
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("invalid ticket queue count");
}
return value;
};
const [cmsOpen, helpOpen] = await Promise.all([
options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
options?.strict ? readCount(help) : readCount(help).catch(() => 0),
db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
]);
return { cmsOpen, helpOpen };
}
+8 -14
View File
@@ -33,20 +33,14 @@ describe("cached (memory-only, no Redis)", () => {
});
it("recomputes after the TTL expires", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
let count = 0;
const fn = vi.fn(async () => ++count);
const key = `ttl-${Math.random()}`;
expect(await cached(key, 100, fn)).toBe(1);
expect(await cached(key, 100, fn)).toBe(1);
vi.advanceTimersByTime(100);
expect(await cached(key, 100, fn)).toBe(2);
expect(fn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
let count = 0;
const fn = vi.fn(async () => ++count);
const key = `ttl-${Math.random()}`;
expect(await cached(key, 1, fn)).toBe(1);
expect(await cached(key, 1, fn)).toBe(1);
await new Promise((r) => setTimeout(r, 10));
expect(await cached(key, 1, fn)).toBe(2);
expect(fn).toHaveBeenCalledTimes(2);
});
it("invalidates a key so the next read recomputes", async () => {
+1 -1
View File
@@ -75,7 +75,7 @@ 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.startsWith("/admin/") || path.startsWith("/api/")) return true;
return false;
}
@@ -1,43 +0,0 @@
import { eq } from "drizzle-orm";
import {
createHousekeepingPreferencesRepository,
type HousekeepingPreferencesStorage,
} from "@/features/housekeeping/foundation/preferences/repository";
import { type Db, db, HousekeepingUserPreferences } from "@/lib/db";
type HousekeepingPreferencesDb = Pick<Db, "select" | "insert">;
export function createDrizzleHousekeepingPreferencesStorage(
database: HousekeepingPreferencesDb,
): HousekeepingPreferencesStorage {
return {
async findByUserId(userId) {
const rows = await database
.select({
schemaVersion: HousekeepingUserPreferences.schemaVersion,
payload: HousekeepingUserPreferences.payload,
})
.from(HousekeepingUserPreferences)
.where(eq(HousekeepingUserPreferences.userId, userId))
.limit(1);
return rows[0] ?? null;
},
async upsert(row) {
await database
.insert(HousekeepingUserPreferences)
.values(row)
.onDuplicateKeyUpdate({
set: {
schemaVersion: row.schemaVersion,
payload: row.payload,
updatedAt: new Date(),
},
});
},
};
}
export const housekeepingPreferencesRepository =
createHousekeepingPreferencesRepository(
createDrizzleHousekeepingPreferencesStorage(db),
);
-45
View File
@@ -1,45 +0,0 @@
import "server-only";
import { desc, eq } from "drizzle-orm";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "@/features/housekeeping/foundation/contracts";
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
import { createHousekeepingRecentWorkService } from "@/features/housekeeping/foundation/recent/recent-work";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { AdminAuditLog, db } from "@/lib/db";
import { logAudit } from "@/lib/services/audit";
const recentWorkService = createHousekeepingRecentWorkService({
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
async loadRows(userId, limit) {
return db
.select({
action: AdminAuditLog.action,
target: AdminAuditLog.target,
domain: AdminAuditLog.domain,
outcome: AdminAuditLog.outcome,
createdAt: AdminAuditLog.createdAt,
})
.from(AdminAuditLog)
.where(eq(AdminAuditLog.userId, userId))
.orderBy(desc(AdminAuditLog.id))
.limit(limit);
},
writeAudit: logAudit,
});
export async function loadHousekeepingRecentWork(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>> {
return recentWorkService.load(context);
}
export async function recordHousekeepingRouteVisit(
routeId: string,
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
return recentWorkService.recordVisit(routeId, context);
}
+5
View File
@@ -35,6 +35,11 @@ describe("admin import backend contract", () => {
expect(source, path).toContain("PERMS.ASSETS_IMPORT");
});
it("does not leave import actions as successful no-op stubs", () => {
const source = readFileSync("src/actions/import-furni.ts", "utf8");
expect(source).not.toContain("deleted: 0, remaining: 0");
});
it("keeps badge import ExternalTexts + WebsiteBadges in sync", () => {
const service = readFileSync("src/lib/services/import-badge.ts", "utf8");
expect(service).toContain("ExternalTexts.json");
+9 -16
View File
@@ -1,25 +1,18 @@
import { describe, expect, it } from "vitest";
import { shouldRedirectHousekeepingRequest } from "./proxy-access";
import { shouldRedirectAdminRequest } from "./proxy-access";
describe("shouldRedirectHousekeepingRequest", () => {
it("redirects anonymous Housekeeping requests before rendering", () => {
expect(shouldRedirectHousekeepingRequest("/ase", null)).toBe(true);
describe("shouldRedirectAdminRequest", () => {
it("redirects anonymous admin requests before rendering", () => {
expect(shouldRedirectAdminRequest("/admin", null)).toBe(true);
});
it("defers every authenticated rank to database authorization", () => {
expect(
shouldRedirectHousekeepingRequest("/ase/system/access/permissions", {
rank: 1,
}),
).toBe(false);
expect(
shouldRedirectHousekeepingRequest("/ase/system/access/permissions", {
rank: 2000,
}),
).toBe(false);
expect(shouldRedirectHousekeepingRequest("/api/admin/csrf", null)).toBe(
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 1 })).toBe(
false,
);
expect(shouldRedirectHousekeepingRequest("/news", null)).toBe(false);
expect(
shouldRedirectAdminRequest("/admin/permissions", { rank: 2000 }),
).toBe(false);
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
});
});
+2 -2
View File
@@ -3,10 +3,10 @@ export interface ProxyToken {
rank?: unknown;
}
export function shouldRedirectHousekeepingRequest(
export function shouldRedirectAdminRequest(
pathname: string,
token: ProxyToken | null,
): boolean {
if (pathname !== "/ase" && !pathname.startsWith("/ase/")) return false;
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
return token === null;
}
+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>`
: "") +
`<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}/admin/alerts">view alerts</a></p>`;
try {
return await sendMail(to, subject, html);
-72
View File
@@ -87,61 +87,6 @@ describe("logAudit", () => {
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
});
it("redacts sensitive keys recursively in nested objects and arrays", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "update",
target: "user",
before: {
profile: {
authTicket: "private-ticket",
credentials: { newPassword: "one-time-password" },
},
integrations: [{ api_key: "private-key" }],
},
});
const before = JSON.parse(insertValues.mock.calls[0][0].before);
expect(before.profile.authTicket).toBe("[Redacted]");
expect(before.profile.credentials.newPassword).toBe("[Redacted]");
expect(before.integrations[0].api_key).toBe("[Redacted]");
});
it("persists correlation, domain, outcome, reason, and IP evidence", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "ban",
target: "user",
correlationId: "corr-123",
domain: "people",
outcome: "denied",
reason: "Policy requirement was not met",
ipAddress: "127.0.0.1",
});
expect(insertValues.mock.calls[0][0]).toMatchObject({
correlationId: "corr-123",
domain: "people",
outcome: "denied",
reason: "Policy requirement was not met",
ipAddress: "127.0.0.1",
});
});
it("writes through the injected transaction when one is supplied", async () => {
const transactionValues = vi.fn().mockResolvedValue({ id: 1 });
const transactionInsert = vi.fn(() => ({ values: transactionValues }));
await logAudit({ userId: 1, action: "update", target: "settings" }, {
insert: transactionInsert,
} as never);
expect(transactionInsert).toHaveBeenCalledOnce();
expect(transactionValues).toHaveBeenCalledOnce();
expect(insertValues).not.toHaveBeenCalled();
});
it("omits diff when only before or after is missing", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
@@ -241,21 +186,4 @@ describe("getAuditLogs", () => {
const result = await getAuditLogs();
expect(result.rows[0].username).toBe("User #99");
});
it("fails closed beyond the redaction depth cap without mutating the input", async () => {
const sentinel = "raw-depth-secret";
const deep = {
a: { b: { c: { d: { e: { f: { g: { secret: sentinel } } } } } } },
};
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
userId: 1,
action: "update",
target: "user",
before: { deep, state: "before" },
after: { deep, state: "after" },
});
const data = insertValues.mock.calls[0][0];
expect(`${data.before}${data.after}${data.diff}`).not.toContain(sentinel);
expect(deep.a.b.c.d.e.f.g.secret).toBe(sentinel);
});
});
+5 -34
View File
@@ -1,28 +1,13 @@
import { count, desc, inArray, like, or } from "drizzle-orm";
import type { HousekeepingDomainId } from "@/features/housekeeping/migration/types";
import { AdminAuditLog, type Db, db, User } from "@/lib/db";
import { AdminAuditLog, db, User } from "@/lib/db";
export interface AuditEntry {
interface AuditEntry {
userId: number;
action: string;
target: string;
targetId?: number;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
correlationId?: string;
outcome?: "intent" | "success" | "failure" | "partial" | "denied";
reason?: string;
domain?: HousekeepingDomainId;
ipAddress?: string;
}
export type HousekeepingAuditTransaction = Pick<Db, "insert">;
export interface HousekeepingAuditWriter {
write(
entry: AuditEntry,
transaction?: HousekeepingAuditTransaction,
): Promise<void>;
}
const SENSITIVE_KEY_RE =
@@ -30,8 +15,7 @@ const SENSITIVE_KEY_RE =
const REDACTED = "[Redacted]";
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
if (depth > 6) return REDACTED;
if (value == null) return value;
if (depth > 6 || value == null) return value;
if (Array.isArray(value))
return value.map((v) => sanitizeAuditPayload(v, depth + 1));
if (typeof value !== "object") return value;
@@ -63,10 +47,7 @@ function computeDiff(
return Object.keys(diff).length > 0 ? diff : null;
}
export async function logAudit(
entry: AuditEntry,
transaction?: HousekeepingAuditTransaction,
): Promise<void> {
export async function logAudit(entry: AuditEntry): Promise<void> {
const sanitizedBefore = entry.before
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
: undefined;
@@ -75,8 +56,7 @@ export async function logAudit(
: undefined;
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
const auditWriter: HousekeepingAuditTransaction = transaction ?? db;
await auditWriter.insert(AdminAuditLog).values({
await db.insert(AdminAuditLog).values({
userId: entry.userId,
action: entry.action,
target: entry.target,
@@ -84,19 +64,10 @@ export async function logAudit(
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
diff: diff ? JSON.stringify(diff) : null,
correlationId: entry.correlationId,
outcome: entry.outcome,
reason: entry.reason,
domain: entry.domain,
ipAddress: entry.ipAddress,
createdAt: new Date().toISOString(),
});
}
export const housekeepingAuditWriter: HousekeepingAuditWriter = {
write: logAudit,
};
interface GetLogsOptions {
search?: string;
page?: number;
+3 -14
View File
@@ -134,16 +134,13 @@ async function fetchWithFallback(url: string): Promise<string> {
export async function fetchSourceFurnidata(
url: string,
now = Date.now(),
signal?: AbortSignal,
): Promise<SourceFurni[]> {
signal?.throwIfAborted();
const hit = cache.get(url);
if (hit && now && now - hit.ts < TTL) return hit.list;
let body: string;
try {
const timeout = AbortSignal.timeout(30000);
const res = await fetch(url, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
signal: AbortSignal.timeout(30000),
headers: browserHeaders(),
});
if (res.ok) {
@@ -162,8 +159,7 @@ export async function fetchSourceFurnidata(
} else {
body = await fetchWithFallback(url);
}
} catch (error) {
if (signal?.aborted) throw error;
} catch {
body = await fetchWithFallback(url);
}
if (body.trimStart().startsWith("<")) {
@@ -247,13 +243,11 @@ export async function cloneSingleFurni(params: {
source: CloneSource;
entry: SourceFurni;
onProgress?: (status: string) => void;
signal?: AbortSignal;
/** Skip the per-item FurnitureData.json write and return the entry instead,
* so batch callers can append everything in one lock + write cycle. */
deferFurniData?: boolean;
}): Promise<CloneResult> {
const { source, entry, onProgress, signal, deferFurniData } = params;
signal?.throwIfAborted();
const { source, entry, onProgress, deferFurniData } = params;
const { classname, itemType } = entry;
const warnings: string[] = [];
@@ -293,7 +287,6 @@ export async function cloneSingleFurni(params: {
// Download .nitro + icon directly from the source hotel.
onProgress?.("downloading");
signal?.throwIfAborted();
const nitroPath = getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${classname}.nitro`,
@@ -307,7 +300,6 @@ export async function cloneSingleFurni(params: {
nitroPath,
{
maxRetries: 2,
signal,
},
);
if (!dl.ok) {
@@ -342,7 +334,6 @@ export async function cloneSingleFurni(params: {
{
maxRetries: 1,
validate: "png",
signal,
},
);
if (!iconDl.ok) {
@@ -375,7 +366,6 @@ export async function cloneSingleFurni(params: {
}
onProgress?.("writing_db");
signal?.throwIfAborted();
const stackHeight =
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
@@ -432,7 +422,6 @@ export async function cloneSingleFurni(params: {
// AND our offerid. The source's offerid points at the source hotel's sprite
// ids, which would silently break the emulator's catalog offer lookup.
onProgress?.("writing_furnidata");
signal?.throwIfAborted();
const furniDataEntry = {
...entry,
id: newId,
+2 -10
View File
@@ -89,10 +89,8 @@ export async function importClothingSet(params: {
setType: string;
setId: number;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportClothingSetResult> {
const { setType, setId, onProgress, signal } = params;
signal?.throwIfAborted();
const { setType, setId, onProgress } = params;
const warnings: string[] = [];
const [officialFd, officialLibs] = await Promise.all([
@@ -138,17 +136,12 @@ export async function importClothingSet(params: {
let hardFail = false;
for (const libId of requiredLibIds) {
signal?.throwIfAborted();
const officialLib = officialLibs.find((l) => l.id === libId);
if (!officialLib) continue;
if (!localLibIds.has(libId)) {
onProgress?.(`importing library ${libId}`);
const result = await importSingleFigure({
lib: libId,
onProgress,
signal,
});
const result = await importSingleFigure({ lib: libId, onProgress });
if (!result.ok) {
hardFail = true;
warnings.push(
@@ -176,7 +169,6 @@ export async function importClothingSet(params: {
}
const mappedSet = mapOfficialSet(set);
signal?.throwIfAborted();
await mergeSet(setType, st.paletteId, mappedSet as never);
if (palette) {
+2 -9
View File
@@ -76,10 +76,8 @@ export async function importSingleEffect(params: {
type: string;
revision: number;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportEffectResult> {
const { id, lib, type, revision, onProgress, signal } = params;
signal?.throwIfAborted();
const { id, lib, type, revision, onProgress } = params;
const warnings: string[] = [];
await ensureEffectDirs();
@@ -102,10 +100,7 @@ export async function importSingleEffect(params: {
error: `Could not resolve SWF URL: ${(err as Error).message}`,
};
}
const dl = await downloadFile(swfUrl, swfPath, {
validate: "swf",
signal,
});
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (!dl.ok)
return {
ok: false,
@@ -116,7 +111,6 @@ export async function importSingleEffect(params: {
// Convert SWF → Nitro
onProgress?.("converting");
signal?.throwIfAborted();
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
@@ -133,7 +127,6 @@ export async function importSingleEffect(params: {
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
onProgress?.("writing_map");
signal?.throwIfAborted();
try {
const entry: EffectMapEntry = { id, lib, type, revision };
await mergeEffect(entry);
+2 -8
View File
@@ -78,10 +78,8 @@ async function nitroPathFor(lib: string): Promise<string> {
export async function importSingleFigure(params: {
lib: string;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportFigureResult> {
const { lib, onProgress, signal } = params;
signal?.throwIfAborted();
const { lib, onProgress } = params;
const warnings: string[] = [];
await ensureFigureDirs();
@@ -101,10 +99,7 @@ export async function importSingleFigure(params: {
error: `Could not resolve SWF URL: ${(err as Error).message}`,
};
}
const dl = await downloadFile(swfUrl, swfPath, {
validate: "swf",
signal,
});
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (!dl.ok)
return {
ok: false,
@@ -114,7 +109,6 @@ export async function importSingleFigure(params: {
};
onProgress?.("converting");
signal?.throwIfAborted();
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
+4 -18
View File
@@ -403,13 +403,10 @@ async function syncDirMissingFiles(
* so all imported furniture stays renderable in-game even when a mirror
* write was skipped.
*/
export async function syncAssetsToGamedataBundle(
signal?: AbortSignal,
): Promise<{
export async function syncAssetsToGamedataBundle(): Promise<{
copiedNitros: string[];
copiedIcons: string[];
}> {
signal?.throwIfAborted();
if (process.platform === "win32")
return { copiedNitros: [], copiedIcons: [] };
@@ -426,7 +423,6 @@ export async function syncAssetsToGamedataBundle(
"bundled/furniture",
);
if (nitroDir !== nitroTarget) {
signal?.throwIfAborted();
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
entry.endsWith(".nitro"),
);
@@ -438,7 +434,6 @@ export async function syncAssetsToGamedataBundle(
"icons",
);
if (iconDir !== iconTarget) {
signal?.throwIfAborted();
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
entry.endsWith("_icon.png"),
);
@@ -513,7 +508,6 @@ export async function importSingleFurni(params: {
skipFurniDataWrite?: boolean;
updateExisting?: boolean;
onProgress?: (status: string) => void;
signal?: AbortSignal;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
sourceSwfBaseUrl?: string;
/** Per-source nitro bundle + icon base URL. */
@@ -531,12 +525,10 @@ export async function importSingleFurni(params: {
skipFurniDataWrite,
updateExisting,
onProgress,
signal,
sourceSwfBaseUrl,
nitroBaseUrl: sourceNitroBaseUrl,
iconBaseUrl: sourceIconBaseUrl,
} = params;
signal?.throwIfAborted();
const warnings: string[] = [];
// Check if already exists by classname OR by spriteId (primary key collision).
@@ -612,7 +604,6 @@ export async function importSingleFurni(params: {
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
signal?.throwIfAborted();
try {
if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
@@ -669,7 +660,6 @@ export async function importSingleFurni(params: {
// fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists).
onProgress?.("downloading");
signal?.throwIfAborted();
const iconColorAware = classname.replace(/\*/g, "_");
@@ -711,8 +701,8 @@ export async function importSingleFurni(params: {
// Download icon + SWF concurrently (independent assets). Each tries its
// candidate URLs in order until one succeeds.
const [iconOkResult, swfOk] = await Promise.all([
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
tryDownloadCandidates(iconUrls, iconPath, "png"),
tryDownloadCandidates(swfUrls, swfPath, "swf"),
]);
let iconOk = iconOkResult;
@@ -725,10 +715,7 @@ export async function importSingleFurni(params: {
!existsSync(/*turbopackIgnore: true*/ nitroPath)
) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, {
validate: "png",
signal,
});
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
if (dl.ok) nitroDownloadOk = true;
}
@@ -757,7 +744,6 @@ export async function importSingleFurni(params: {
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.("converting");
signal?.throwIfAborted();
let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
+1 -8
View File
@@ -635,21 +635,14 @@ export interface FixAllResult {
/** Run every repair in a sensible order, then return before/after health. */
export async function fixEverything(
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
opts: { dedupePages?: boolean } = {},
): Promise<FixAllResult> {
opts.signal?.throwIfAborted();
const healthBefore = await getFurniHealth();
opts.signal?.throwIfAborted();
const sprite = await fixSpriteIds();
opts.signal?.throwIfAborted();
const offers = await fixCatalogOffers();
opts.signal?.throwIfAborted();
const reconcile = await reconcileIds();
opts.signal?.throwIfAborted();
const dedup = await removeDuplicates(opts.dedupePages ?? true);
opts.signal?.throwIfAborted();
const align = await forceItemsBaseIdsToFurnidata(true);
opts.signal?.throwIfAborted();
const healthAfter = await getFurniHealth();
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
}
+7 -22
View File
@@ -17,9 +17,7 @@ export async function writeBadgeToExternalTexts(
code: string,
name: string,
description: string,
signal?: AbortSignal,
): Promise<ImportBadgeResult> {
signal?.throwIfAborted();
const gamedataRoot = await getGamedataRoot();
if (!gamedataRoot) {
return {
@@ -37,7 +35,6 @@ export async function writeBadgeToExternalTexts(
const raw = await fs.readFile(extPath, "utf-8");
texts = JSON.parse(raw) as Record<string, string>;
} catch (err) {
if (signal?.aborted) throw err;
// File not present yet (fresh deployment) — start with an empty object.
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(
@@ -52,7 +49,6 @@ export async function writeBadgeToExternalTexts(
}
texts = {};
}
signal?.throwIfAborted();
if (texts[nameKey] !== undefined) {
return {
@@ -67,11 +63,9 @@ export async function writeBadgeToExternalTexts(
try {
await fs.mkdir(path.dirname(extPath), { recursive: true });
signal?.throwIfAborted();
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
return { ok: true };
} catch (err) {
if (signal?.aborted) throw err;
console.warn(
"[import-badges] Failed to update ExternalTexts.json:",
(err as Error).message,
@@ -89,9 +83,7 @@ export async function upsertWebsiteBadge(
code: string,
name: string,
description: string,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted();
const now = new Date();
await db
.insert(WebsiteBadges)
@@ -115,28 +107,21 @@ export async function upsertWebsiteBadge(
* Single import path: ExternalTexts.json + WebsiteBadges.
* Callers should gate with ASSETS_IMPORT.
*/
export async function importBadgeSynced(
input: {
code: string;
name: string;
description: string;
},
signal?: AbortSignal,
): Promise<ImportBadgeResult> {
signal?.throwIfAborted();
export async function importBadgeSynced(input: {
code: string;
name: string;
description: string;
}): Promise<ImportBadgeResult> {
const texts = await writeBadgeToExternalTexts(
input.code,
input.name,
input.description,
signal,
);
if (!texts.ok) return texts;
try {
signal?.throwIfAborted();
await upsertWebsiteBadge(input.code, input.name, input.description, signal);
} catch (error) {
if (signal?.aborted) throw error;
await upsertWebsiteBadge(input.code, input.name, input.description);
} catch {
return { ok: false, error: "Failed to import badge", status: 500 };
}
+3 -14
View File
@@ -22,11 +22,9 @@ export async function tryDownloadCandidates(
urls: string[],
destPath: string,
kind: "swf" | "png",
signal?: AbortSignal,
): Promise<boolean> {
for (const url of urls) {
signal?.throwIfAborted();
const res = await downloadFile(url, destPath, { validate: kind, signal });
const res = await downloadFile(url, destPath, { validate: kind });
if (res.ok) return true;
}
return false;
@@ -35,26 +33,18 @@ export async function tryDownloadCandidates(
export async function downloadFile(
url: string,
destPath: string,
options?: {
maxRetries?: number;
validate?: "swf" | "png";
signal?: AbortSignal;
},
options?: { maxRetries?: number; validate?: "swf" | "png" },
): Promise<{ ok: boolean; size: number }> {
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
options?.signal?.throwIfAborted();
if (attempt > 0) {
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
}
const timeout = AbortSignal.timeout(15000);
const res = await fetch(url, {
signal: options?.signal
? AbortSignal.any([options.signal, timeout])
: timeout,
signal: AbortSignal.timeout(15000),
headers: browserHeaders("image", {
Accept: "image/png,image/*,*/*;q=0.8",
}),
@@ -92,7 +82,6 @@ export async function downloadFile(
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
return { ok: true, size: buffer.length };
} catch (err) {
if (options?.signal?.aborted) throw err;
if (attempt === maxRetries) {
console.warn(
`[import-download] Download error ${url}:`,
-47
View File
@@ -21,53 +21,6 @@ export interface ModerationResult {
reason?: string;
}
export type ModerationAction =
| { readonly action: "kick"; readonly userId: number }
| {
readonly action: "mute";
readonly userId: number;
readonly duration: number;
}
| { readonly action: "unmute"; readonly userId: number }
| {
readonly action: "alert";
readonly userId: number;
readonly message: string;
}
| { readonly action: "room-kick"; readonly roomId: number }
| {
readonly action: "broadcast";
readonly message: string;
readonly type: "hotel" | "staff";
};
export interface ModerationActionTransport {
disconnectUser(userId: number): Promise<boolean>;
muteUser(userId: number, duration: number): Promise<boolean>;
unmuteUser(userId: number): Promise<boolean>;
alertUser(userId: number, message: string): Promise<boolean>;
kickAll(roomId: number): Promise<boolean>;
hotelAlert(message: string): Promise<boolean>;
staffAlert(message: string): Promise<boolean>;
}
// Execute one already-authorized moderation effect and expose delivery truth.
export async function executeModerationAction(
transport: ModerationActionTransport,
input: ModerationAction,
): Promise<boolean> {
if (input.action === "kick") return transport.disconnectUser(input.userId);
if (input.action === "mute")
return transport.muteUser(input.userId, input.duration);
if (input.action === "unmute") return transport.unmuteUser(input.userId);
if (input.action === "alert")
return transport.alertUser(input.userId, input.message);
if (input.action === "room-kick") return transport.kickAll(input.roomId);
return input.type === "hotel"
? transport.hotelAlert(input.message)
: transport.staffAlert(input.message);
}
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
// Bound the AI call so a slow/hung endpoint can't stall a server action.
const OPENAI_TIMEOUT_MS = 5_000;
+2 -8
View File
@@ -45,10 +45,8 @@ function nitroPathFor(lib: string): string {
export async function importSinglePet(params: {
lib: string;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportPetResult> {
const { lib, onProgress, signal } = params;
signal?.throwIfAborted();
const { lib, onProgress } = params;
const warnings: string[] = [];
await ensurePetDirs();
@@ -68,10 +66,7 @@ export async function importSinglePet(params: {
error: `Could not resolve SWF URL: ${(err as Error).message}`,
};
}
const dl = await downloadFile(swfUrl, swfPath, {
validate: "swf",
signal,
});
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (!dl.ok)
return {
ok: false,
@@ -81,7 +76,6 @@ export async function importSinglePet(params: {
};
onProgress?.("converting");
signal?.throwIfAborted();
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
+2 -6
View File
@@ -104,9 +104,7 @@ export interface RepairEvent {
export async function repairMissingIcons(
onEvent?: (evt: RepairEvent) => void,
options?: { signal?: AbortSignal },
): Promise<{ succeeded: number; failed: number; skipped: number }> {
options?.signal?.throwIfAborted();
let succeeded = 0;
let failed = 0;
let skipped = 0;
@@ -206,7 +204,6 @@ export async function repairMissingIcons(
onEvent?.({ type: "started", total });
const processItem = async (classname: string) => {
options?.signal?.throwIfAborted();
// Special items (badges, pets, effects, bots, sticky notes) don't have
// .png icon files to repair. Skip them.
if (dbSkipSet.has(classname)) {
@@ -315,7 +312,7 @@ export async function repairMissingIcons(
targets.iconDir,
fileName,
),
{ maxRetries: 1, validate: "png", signal: options?.signal },
{ maxRetries: 1, validate: "png" },
);
if (dl.ok) {
const icon = await fs.readFile(
@@ -353,7 +350,7 @@ export async function repairMissingIcons(
const dl = await downloadFile(
`${source.nitroBaseUrl}/${classname}.nitro`,
nitroTmp,
{ maxRetries: 1, signal: options?.signal },
{ maxRetries: 1 },
);
if (dl.ok) {
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
@@ -393,7 +390,6 @@ export async function repairMissingIcons(
};
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
options?.signal?.throwIfAborted();
const classname = item.item_name;
const result = await processItem(classname);
if (result.status === "skipped") {
+2 -8
View File
@@ -106,9 +106,7 @@ export interface RepairNitroEvent {
export async function repairMissingNitros(
onEvent?: (evt: RepairNitroEvent) => void,
options?: { signal?: AbortSignal },
): Promise<{ succeeded: number; failed: number; skipped: number }> {
options?.signal?.throwIfAborted();
let succeeded = 0;
let failed = 0;
let skipped = 0;
@@ -203,7 +201,6 @@ export async function repairMissingNitros(
onEvent?.({ type: "started", total });
const processItem = async (classname: string) => {
options?.signal?.throwIfAborted();
// Special items (badges, pets, effects, bots, sticky notes) don't have
// furni .nitro bundles to repair. Skip them.
if (skipSet.has(classname)) {
@@ -279,7 +276,7 @@ export async function repairMissingNitros(
const dl = await downloadFile(
`${source.nitroBaseUrl}/${nitroName}`,
tmpPath,
{ maxRetries: 1, signal: options?.signal },
{ maxRetries: 1 },
);
if (dl.ok) {
// Validate it is a real Nitro bundle before writing it
@@ -318,7 +315,6 @@ export async function repairMissingNitros(
};
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
options?.signal?.throwIfAborted();
const classname = item.item_name;
const result = await processItem(classname);
if (result.status === "skipped") {
@@ -388,14 +384,13 @@ export async function repairMissingNitros(
*/
export async function addSize32AllNitros(
onEvent?: (evt: RepairNitroEvent) => void,
opts?: { concurrency?: number; signal?: AbortSignal },
opts?: { concurrency?: number },
): Promise<{
succeeded: number;
failed: number;
skipped: number;
existing: number;
}> {
opts?.signal?.throwIfAborted();
let succeeded = 0;
let failed = 0;
let skipped = 0;
@@ -447,7 +442,6 @@ export async function addSize32AllNitros(
[...fileNames],
opts?.concurrency ?? 12,
async (fileName, i) => {
opts?.signal?.throwIfAborted();
let buf: Buffer | null = null;
for (const dir of nitroDirs) {
try {
-50
View File
@@ -19,56 +19,6 @@ export interface TicketReplyDb {
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
}
export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
function boundedDecimalPattern(maximum: string): RegExp {
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
for (let index = 0; index < maximum.length; index += 1) {
const maximumDigit = Number(maximum[index]);
const minimumDigit = index === 0 ? 1 : 0;
const upperDigit = maximumDigit - 1;
if (upperDigit < minimumDigit) continue;
const digit =
upperDigit === minimumDigit
? String(minimumDigit)
: `[${minimumDigit}-${upperDigit}]`;
alternatives.push(
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
);
}
alternatives.push(maximum);
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
}
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
MAX_UNSIGNED_TICKET_ID.toString(),
);
// Canonicalize a SQL BIGINT identifier without passing through Number.
export function canonicalTicketId(value: string | number | bigint): bigint {
let parsed: bigint;
try {
if (typeof value === "bigint") {
parsed = value;
} else if (typeof value === "string") {
if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
throw new Error("noncanonical identifier");
}
parsed = BigInt(value);
} else if (Number.isSafeInteger(value) && value > 0) {
parsed = BigInt(value);
} else {
throw new Error("unsafe identifier");
}
} catch {
throw new Error("invalid ticket identifier");
}
if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
throw new Error("invalid ticket identifier");
}
return parsed;
}
export async function createOwnedTicketReply(
db: TicketReplyDb,
input: { ticketId: bigint; userId: number; content: string },
-9
View File
@@ -234,7 +234,6 @@ export async function uploadSingleFurni(params: {
nitroBuffer: Buffer;
iconBuffer?: Buffer | null;
generateSql?: boolean;
signal?: AbortSignal;
}): Promise<UploadResult> {
const {
classname,
@@ -251,9 +250,7 @@ export async function uploadSingleFurni(params: {
nitroBuffer,
iconBuffer,
generateSql = false,
signal,
} = params;
signal?.throwIfAborted();
const warnings: string[] = [];
if (!/^[\w\-.*]+$/.test(classname)) {
@@ -283,7 +280,6 @@ export async function uploadSingleFurni(params: {
}
await ensureDirectories();
signal?.throwIfAborted();
const assetTargets = await getFurniAssetWriteTargets();
const { iconDir, nitroDir } = assetTargets;
@@ -301,7 +297,6 @@ export async function uploadSingleFurni(params: {
};
}
await fs.writeFile(nitroPath, nitroBuffer);
signal?.throwIfAborted();
let iconFileName: string | null = null;
let iconPath: string | null = null;
@@ -312,7 +307,6 @@ export async function uploadSingleFurni(params: {
}
const mirroredPaths: string[] = [];
signal?.throwIfAborted();
await mirrorUploadedAsset(
nitroPath,
nitroFileName,
@@ -352,7 +346,6 @@ export async function uploadSingleFurni(params: {
const interactionModesCount = autoInteraction.interactionModesCount;
let newId: number;
signal?.throwIfAborted();
try {
newId = await allocateItemsBaseId(async (nextId) => {
await db.execute(sql`
@@ -379,7 +372,6 @@ export async function uploadSingleFurni(params: {
}
try {
signal?.throwIfAborted();
const furniEntry = buildFurniEntry({
id: newId,
classname,
@@ -398,7 +390,6 @@ export async function uploadSingleFurni(params: {
let catalogItemId: number | null = null;
try {
signal?.throwIfAborted();
// Skip when the item already has a catalog row — re-uploads must never
// create a second row for the same item_ids.
const [existingCatalog] = (await db.execute(sql`
+107
View File
@@ -0,0 +1,107 @@
import { toast } from "sonner";
import { adminFetch } from "@/lib/admin-fetch";
export type SseEvent = Record<string, unknown>;
/**
* Read an SSE response body and invoke `onEvent` for each `data:` JSON payload.
*/
export async function readSseStream(
body: ReadableStream<Uint8Array>,
onEvent: (event: SseEvent) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
try {
while (true) {
if (signal?.aborted) {
await reader.cancel();
break;
}
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
onEvent(JSON.parse(part.slice(6)) as SseEvent);
} catch {
/* skip malformed events */
}
}
}
} finally {
reader.releaseLock();
}
}
/**
* POST JSON to an admin SSE import endpoint and drive the standard
* item_progress / batch_complete callbacks used by clothing & clone clients.
*/
export async function runSseImport(
url: string,
body: unknown,
onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void,
signal?: AbortSignal,
onFailed?: (classname: string, error?: string) => void,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
toast.error(
typeof data.error === "string"
? data.error
: `Import failed (${res.status})`,
);
onComplete(0, 0);
return;
}
if (!res.body) {
toast.error("No response stream");
onComplete(0, 0);
return;
}
let succeeded = 0;
let failed = 0;
await readSseStream(
res.body,
(evt) => {
if (evt.type === "item_progress") {
const classname = String(evt.classname ?? "");
if (evt.status === "done") {
onDone(classname);
} else if (evt.status === "failed") {
onFailed?.(classname, String(evt.error ?? ""));
}
}
if (evt.type === "error") {
toast.error(
typeof evt.message === "string"
? evt.message
: "Import finished with errors",
);
}
if (evt.type === "batch_complete") {
succeeded = Number(evt.succeeded ?? 0);
failed = Number(evt.failed ?? 0);
}
},
signal,
);
onComplete(succeeded, failed);
}
+260
View File
@@ -0,0 +1,260 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("staff smoke contract", () => {
it("gates photos delete with PAGES_EDIT", () => {
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
expect(src).toContain("PERMS.PAGES_EDIT");
expect(src).toContain("deletePhoto");
expect(src).toContain("logStaffActivity");
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
"AdminPageShell",
);
});
it("gates bans create/lift", () => {
expect(existsSync("src/app/admin/bans/page.tsx")).toBe(true);
const bans = readFileSync("src/actions/admin-bans.ts", "utf8");
expect(bans).toContain("liftBan");
expect(bans).toContain("createBan");
expect(readFileSync("src/app/admin/bans/page.tsx", "utf8")).toContain(
"AdminPageShell",
);
});
it("wires ban appeal lift on help tickets", () => {
const actions = readFileSync("src/actions/admin-help-tickets.ts", "utf8");
expect(actions).toContain("liftBanFromHelpTicket");
expect(actions).toContain("PERMS.USERS_BAN");
const detail = readFileSync(
"src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx",
"utf8",
);
expect(detail).toContain("liftBanFromHelpTicket");
expect(detail).toContain("canLiftBan");
});
it("exposes sanction timeline on user show", () => {
expect(
existsSync("src/app/admin/users/_components/user-sanction-timeline.tsx"),
).toBe(true);
expect(existsSync("src/app/admin/users/_lib/load-user-sanctions.ts")).toBe(
true,
);
expect(
readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"),
).toContain("UserSanctionTimeline");
});
it("applies CMS SQL from drizzle/migrations (not Prisma)", () => {
expect(existsSync("drizzle/migrations")).toBe(true);
expect(existsSync("prisma")).toBe(false);
expect(readFileSync("scripts/apply-migrations.ts", "utf8")).toContain(
"drizzle/migrations",
);
const pkg = readFileSync("package.json", "utf8");
expect(pkg).not.toContain('"prisma"');
expect(pkg).not.toContain('"@prisma/client"');
expect(pkg).not.toContain("prisma:generate");
expect(pkg).toContain('"db:generate": "drizzle-kit generate"');
expect(pkg).toContain('"db:studio": "drizzle-kit studio"');
expect(pkg).toContain('"db:migrate": "tsx scripts/apply-migrations.ts"');
expect(readFileSync("drizzle.config.ts", "utf8")).toContain(
"./drizzle/drafts",
);
});
it("ships shared ops + ticket queue helpers", () => {
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
const ops = readFileSync("src/lib/admin/ops-health.ts", "utf8");
expect(ops).toContain("redisOk");
expect(ops).toContain("redis.ping");
expect(existsSync("src/lib/admin/ticket-queue-counts.ts")).toBe(true);
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
});
it("ships dynamic admin menu overlay", () => {
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(true);
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
"/admin/menu",
);
expect(
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
).toContain("applyAdminNavConfig");
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
"ADMIN_NAV_CONFIG_KEY",
);
});
it("opts admin/mod out of static caching via instant=false", () => {
// The Cache Components migration replaced `force-dynamic` with
// `export const instant = false` — the legacy export must not survive.
let legacyHits = 0;
function walk(dir: string) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p);
else if (/\.(tsx?|jsx?)$/.test(name)) {
const src = readFileSync(p, "utf8");
if (
/export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/.test(src)
) {
legacyHits++;
}
}
}
}
walk("src/app/admin");
walk("src/app/mod");
expect(legacyHits).toBe(0);
// The root layout carries the single Cache Components opt-out; staff
// pages inherit it (was force-dynamic, then per-layout instant=false).
expect(readFileSync("src/app/layout.tsx", "utf8")).toContain(
"export const instant = false",
);
// Staff layouts must not redeclare the opt-out — that is the root
// layout's job now, and redeclaring it is the legacy per-layout pattern.
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).not.toContain(
"export const instant",
);
expect(readFileSync("src/app/mod/layout.tsx", "utf8")).not.toContain(
"export const instant",
);
});
it("caches analytics via redisCache", () => {
for (const path of [
"src/app/admin/analytics/page.tsx",
"src/app/admin/analytics/activity/page.tsx",
"src/app/admin/analytics/economy/page.tsx",
]) {
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
}
});
it("exports bulk adjust currency and trade lock", () => {
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
expect(src).toContain("bulkAdjustCurrency");
expect(src).toContain("setTradeLock");
expect(src).toContain("tradeLockedUntil");
expect(src).toContain("UsersSettings");
expect(src).toContain("rcon.setTradeLock");
});
it("deletes photos via Drizzle with local file purge helper", () => {
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
expect(src).toContain("CameraWeb");
expect(src).toContain("tryRemoveLocalPhotoFile");
expect(src).toContain("@/lib/admin/photo-files");
expect(src).toContain("@/lib/db");
});
it("guards dual ticket queues on admin and mod", () => {
for (const path of [
"src/app/admin/tickets/desk/page.tsx",
"src/app/admin/help-tickets/page.tsx",
"src/app/mod/tickets/desk/page.tsx",
"src/app/mod/help-tickets/page.tsx",
"src/app/admin/tickets/[id]/page.tsx",
"src/app/admin/help-tickets/[id]/page.tsx",
"src/app/mod/tickets/[id]/page.tsx",
"src/app/mod/help-tickets/[id]/page.tsx",
]) {
expect(existsSync(path), path).toBe(true);
expect(readFileSync(path, "utf8"), path).toContain("TicketQueueBanner");
expect(readFileSync(path, "utf8"), path).toContain(
"fetchTicketQueueOpenCounts",
);
}
});
it("ships unified ticket inbox over both queues", () => {
expect(existsSync("src/lib/admin/ticket-inbox.ts")).toBe(true);
expect(readFileSync("src/lib/admin/ticket-inbox.ts", "utf8")).toContain(
"fetchUnifiedTicketInbox",
);
expect(existsSync("src/components/admin/unified-tickets-table.tsx")).toBe(
true,
);
expect(readFileSync("src/app/admin/tickets/page.tsx", "utf8")).toContain(
"fetchUnifiedTicketInbox",
);
expect(readFileSync("src/app/mod/tickets/page.tsx", "utf8")).toContain(
"fetchUnifiedTicketInbox",
);
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
"ticketInbox",
);
});
it("keeps StatusCard server-safe without client boundary", () => {
expect(
readFileSync("src/components/admin/dashboard.tsx", "utf8"),
).not.toMatch(/^["']use client["']/m);
expect(existsSync("src/components/admin/online-users-widget.tsx")).toBe(
true,
);
expect(
readFileSync("src/components/admin/online-users-widget.tsx", "utf8"),
).toContain("use client");
expect(existsSync("src/app/admin/ads/edit-ad-delete-button.tsx")).toBe(
true,
);
});
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
expect(worker).toContain("checkOpsHealth");
expect(worker).toContain("healthDegraded");
expect(worker).toContain("backupDatabase");
expect(readFileSync("src/lib/services/alert.ts", "utf8")).toContain(
"healthDegraded",
);
expect(readFileSync("src/app/api/health/route.ts", "utf8")).toContain(
"rateLimit",
);
expect(readFileSync("src/actions/admin-alerts.ts", "utf8")).toContain(
"markAllAlertsRead",
);
expect(readFileSync("src/env.ts", "utf8")).toContain("DB_BACKUP_DIR");
});
it("documents local-only photo file purge", () => {
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
"purgeHint",
);
expect(readFileSync("src/messages/en.json", "utf8")).toContain(
"External CDN URLs are not purged",
);
});
it("ships items base admin CRUD", () => {
expect(existsSync("src/app/admin/items/page.tsx")).toBe(true);
expect(existsSync("src/app/admin/items/[id]/page.tsx")).toBe(true);
expect(readFileSync("src/actions/items-base.ts", "utf8")).toContain(
"updateItemsBase",
);
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
"/admin/items",
);
});
it("ships studio hub with synced badge import path", () => {
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
expect(nav).toContain('href: "/admin/studio"');
expect(nav).toContain('labelKey: "studio"');
expect(nav).toContain('"/admin/studio"');
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
const badgeService = readFileSync(
"src/lib/services/import-badge.ts",
"utf8",
);
expect(badgeService).toContain("ExternalTexts.json");
expect(badgeService).toContain("WebsiteBadges");
expect(badgeService).toContain("importBadgeSynced");
});
});