feat(housekeeping): deliver content vertical

This commit is contained in:
Simo committed 2026-08-30 01:54:43 +02:00
1 parent bdab924bdf
commit fd68819d9b
67 files changed
+5742 -1916

No files matched your search

@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import {
CONTENT_COMMAND_IDS,
createContentCommands,
} from "./content-commands";
const expected = [
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
[
"content.engagement.event-type.change",
"event-type.change",
PERMS.EVENTS_EDIT,
],
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
[
"content.engagement.event-prize.change",
"event-prize.change",
PERMS.EVENTS_EDIT,
],
[
"content.engagement.event-winner.add",
"event-winner.add",
PERMS.EVENTS_EDIT,
],
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
[
"content.engagement.poll-question.change",
"poll-question.change",
PERMS.POLLS_EDIT,
],
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
[
"content.editorial.navigation.update",
"navigation.update",
PERMS.SETTINGS_EDIT,
],
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
[
"content.engagement.prefix.change",
"prefix.change",
PERMS.PREFIXES_EDIT,
],
[
"content.engagement.prefix-blacklist.change",
"prefix-blacklist.change",
PERMS.PREFIXES_EDIT,
],
[
"content.engagement.prefix-settings.update",
"prefix-settings.update",
PERMS.PREFIXES_EDIT,
],
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
[
"content.editorial.writeable-box.change",
"writeable-box.change",
PERMS.PAGES_EDIT,
],
[
"content.help.email-template.change",
"email-template.change",
PERMS.PAGES_EDIT,
],
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
[
"content.brand.theme.apply-preset",
"theme.apply-preset",
PERMS.SETTINGS_EDIT,
],
[
"content.brand.theme.custom-change",
"theme.custom-change",
PERMS.SETTINGS_EDIT,
],
[
"content.brand.theme.apply-custom",
"theme.apply-custom",
PERMS.SETTINGS_EDIT,
],
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
[
"content.localization.cms.save",
"translation.cms.save",
PERMS.SETTINGS_EDIT,
],
[
"content.localization.client.save",
"translation.client.save",
PERMS.SETTINGS_EDIT,
],
[
"content.localization.emulator.save",
"translation.emulator.save",
PERMS.SETTINGS_EDIT,
],
] as const;
function context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
}
describe("Content commands", () => {
it("registers the exact complete operation matrix with existing ACLs", () => {
const service = { execute: vi.fn() };
const commands = createContentCommands(service as never);
expect(CONTENT_COMMAND_IDS).toEqual(expected.map(([id]) => id));
expect(
commands.map((command) => [
command.id,
command.operation,
command.capability.slugs[0],
]),
).toEqual(expected);
expect(commands.every((command) => command.owner === "content")).toBe(true);
});
it("marks global brand and localization writes sensitive with reason confirmation", () => {
const commands = createContentCommands({ execute: vi.fn() } as never);
const globalCommands = commands.filter(
(command) =>
command.id.startsWith("content.brand.") ||
command.id.startsWith("content.localization."),
);
expect(globalCommands.length).toBeGreaterThan(0);
expect(
globalCommands.every(
(command) => command.risk === "sensitive" && command.requiresReason,
),
).toBe(true);
expect(
globalCommands.every(
(command) =>
command.capability.mode === "any" &&
command.capability.slugs[0] === PERMS.SETTINGS_EDIT,
),
).toBe(true);
});
it("executes the real mutation service with actor-bound authority", async () => {
const execute = vi.fn(async () => ({
ok: true as const,
data: { before: null, after: { id: "1" } },
correlationId: "command-correlation",
}));
const [command] = createContentCommands({ execute } as never);
const result = await command.execute(
{
capability: context(),
correlationId: "command-correlation",
ipAddress: "127.0.0.1",
},
{ action: "create", title: "Launch" },
);
expect(execute).toHaveBeenCalledWith(
{
correlationId: "command-correlation",
expectedActorId: 42,
},
"article.change",
{ action: "create", title: "Launch" },
);
expect(result).toMatchObject({ ok: true });
});
it("isolates command validation schemas from later caller mutation", () => {
const commands = createContentCommands({ execute: vi.fn() } as never);
for (const command of commands) {
expect(command.input.safeParse(null).success, command.id).toBe(false);
expect(command.rateLimit.attempts).toBeGreaterThan(0);
expect(command.rateLimit.windowMs).toBeGreaterThan(0);
}
});
});
@@ -0,0 +1,84 @@
import "server-only";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
type ContentMutationOperation,
type ContentMutationService,
contentMutationService,
} from "../services/mutations";
const CONTENT_COMMAND_DEFINITIONS = [
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
["content.engagement.event-type.change", "event-type.change", PERMS.EVENTS_EDIT],
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
["content.engagement.event-prize.change", "event-prize.change", PERMS.EVENTS_EDIT],
["content.engagement.event-winner.add", "event-winner.add", PERMS.EVENTS_EDIT],
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
["content.engagement.poll-question.change", "poll-question.change", PERMS.POLLS_EDIT],
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
["content.editorial.navigation.update", "navigation.update", PERMS.SETTINGS_EDIT],
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
["content.engagement.prefix-blacklist.change", "prefix-blacklist.change", PERMS.PREFIXES_EDIT],
["content.engagement.prefix-settings.update", "prefix-settings.update", PERMS.PREFIXES_EDIT],
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
["content.editorial.writeable-box.change", "writeable-box.change", PERMS.PAGES_EDIT],
["content.help.email-template.change", "email-template.change", PERMS.PAGES_EDIT],
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
["content.brand.theme.apply-preset", "theme.apply-preset", PERMS.SETTINGS_EDIT],
["content.brand.theme.custom-change", "theme.custom-change", PERMS.SETTINGS_EDIT],
["content.brand.theme.apply-custom", "theme.apply-custom", PERMS.SETTINGS_EDIT],
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
["content.localization.cms.save", "translation.cms.save", PERMS.SETTINGS_EDIT],
["content.localization.client.save", "translation.client.save", PERMS.SETTINGS_EDIT],
["content.localization.emulator.save", "translation.emulator.save", PERMS.SETTINGS_EDIT],
] as const;
export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map(
([id]) => id,
) as ReadonlyArray<(typeof CONTENT_COMMAND_DEFINITIONS)[number][0]>;
type ContentCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
readonly operation: ContentMutationOperation;
};
const commandInput = z.object({}).catchall(z.unknown());
export function createContentCommands(
service: Pick<ContentMutationService, "execute">,
): readonly ContentCommand[] {
return CONTENT_COMMAND_DEFINITIONS.map(
([id, operation, permission]): ContentCommand => ({
id,
owner: "content",
operation,
risk: "sensitive",
capability: anyCapability(permission),
input: commandInput,
requiresReason:
id.startsWith("content.brand.") ||
id.startsWith("content.localization."),
rateLimit: { attempts: 10, windowMs: 60_000 },
execute: (context, input) =>
service.execute(
{
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
operation,
input,
),
}),
);
}
export const CONTENT_COMMANDS = createContentCommands(contentMutationService);
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { loadContentInboxItems } from "./inbox-production";
import { loadContentSearchCandidates } from "./search-production";
import { loadContentWidget } from "./widgets-production";
const { run } = vi.hoisted(() => ({ run: vi.fn() }));
vi.mock("./queries/content-queries", () => ({
contentQuery: { run },
}));
const context = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
function result(
routeId: string,
total: number,
items: readonly Record<string, unknown>[] = [],
) {
return {
ok: true as const,
data: {
kind: routeId.split(".")[1],
items,
total,
partialDependencies: [],
},
correlationId: "provider-production",
};
}
describe("Content production providers", () => {
beforeEach(() => {
vi.clearAllMocks();
run.mockImplementation(async (_context, input) =>
result(input.routeId, input.routeId.includes("articles") ? 7 : 3),
);
});
it("returns only truthful persisted counts from editorial and localization widgets", async () => {
const signal = new AbortController().signal;
await expect(loadContentWidget("editorial", context, signal)).resolves.toEqual(
{ articles: 7 },
);
await expect(
loadContentWidget("localization", context, signal),
).resolves.toEqual({ stores: 3 });
});
it("loads real search candidates from bounded query routes", async () => {
run.mockImplementation(async (_context, input) =>
result(input.routeId, 1, [
{
id: "9",
title: "Launch",
description: "Published",
href: "/ase/content/editorial/articles/9",
},
]),
);
const candidates = await loadContentSearchCandidates(
"articles",
context,
"launch",
25,
);
expect(candidates).toEqual([
expect.objectContaining({
id: "content.editorial.articles:9",
href: "/ase/content/editorial/articles/9",
}),
]);
expect(run).toHaveBeenCalledWith(
context,
expect.objectContaining({ list: { search: "launch", pageSize: 25, offset: 0 } }),
);
});
it("builds publication inbox items only from real query rows", async () => {
run.mockImplementation(async (_context, input) =>
result(input.routeId, 1, [
{
id: "5",
title: "Release",
status: "published",
updatedAt: new Date().toISOString(),
href: "/ase/content/editorial/articles/5",
},
]),
);
const items = await loadContentInboxItems(
"publication",
context,
new AbortController().signal,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
itemId: "5",
sourceId: "content.publication",
href: "/ase/content/editorial/articles/5",
});
});
});
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../../foundation/contracts";
import {
CONTENT_INBOX_SOURCE_IDS,
createContentInboxSources,
} from "./inbox";
import {
CONTENT_SEARCH_PROVIDER_IDS,
createContentSearchProviders,
} from "./search";
import { CONTENT_WIDGET_IDS, createContentWidgets } from "./widgets";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
describe("Content search providers", () => {
it("uses the four exact IDs, filters item capabilities, and caps results at 25", async () => {
const visible = anyCapability(PERMS.NEWS_VIEW);
const hidden = anyCapability(PERMS.EVENTS_VIEW);
const candidates = Array.from({ length: 30 }, (_, index) => ({
id: `article-${index}`,
title: `Article ${index}`,
href: `/ase/content/editorial/articles/${index + 1}`,
capability: index === 0 ? hidden : visible,
}));
const providerAdapters = {
articles: vi.fn(async () => candidates),
events: vi.fn(async () => []),
media: vi.fn(async () => []),
help: vi.fn(async () => []),
};
const providers = createContentSearchProviders(providerAdapters);
expect(CONTENT_SEARCH_PROVIDER_IDS).toEqual([
"content.articles",
"content.events",
"content.media",
"content.help",
]);
expect(providers.map((provider) => provider.id)).toEqual(
CONTENT_SEARCH_PROVIDER_IDS,
);
const result = await providers[0].search(context([PERMS.NEWS_VIEW]), {
term: " launch ",
limit: 999,
});
expect(providerAdapters.articles).toHaveBeenCalledWith(
expect.anything(),
"launch",
25,
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.data).toHaveLength(25);
expect(result.data.some((item) => item.id === "article-0")).toBe(false);
});
it.each([
"/ase/content/%2e%2e/system",
"/ase/content/%252e%252e/system",
"/ase/content/%252f..%252fsystem",
"/ase/content/%255c..%255csystem",
"https://example.test/ase/content/editorial",
"//example.test/ase/content/editorial",
])("rejects normalized and double-encoded traversal href %s", async (href) => {
const adapters = {
articles: async () => [
{
id: "unsafe",
title: "Unsafe",
href,
capability: anyCapability(PERMS.NEWS_VIEW),
},
],
events: async () => [],
media: async () => [],
help: async () => [],
};
const [provider] = createContentSearchProviders(adapters);
const result = await provider.search(context([PERMS.NEWS_VIEW]), {
term: "",
limit: 25,
});
expect(result).toMatchObject({ ok: true, data: [] });
});
});
describe("Content inbox and widgets", () => {
it("provides capability-selective publication and attention sources", async () => {
const publication = vi.fn(async () => []);
const attention = vi.fn(async () => []);
const sources = createContentInboxSources({ publication, attention });
expect(CONTENT_INBOX_SOURCE_IDS).toEqual([
"content.publication",
"content.attention",
]);
const controller = new AbortController();
const news = context([PERMS.NEWS_VIEW]);
await sources[0].getItems(news, controller.signal);
const forbidden = await sources[1].getItems(news, controller.signal);
expect(publication).toHaveBeenCalledTimes(1);
expect(attention).not.toHaveBeenCalled();
expect(forbidden).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
});
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
const adapters = {
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
media: vi.fn(async () => ({ items: 4 })),
localization: vi.fn(async () => ({ stores: 3, pending: 0 })),
};
const widgets = createContentWidgets(adapters);
expect(CONTENT_WIDGET_IDS).toEqual([
"content.editorial-summary",
"content.media-summary",
"content.localization-summary",
]);
expect(widgets.map((widget) => widget.kind)).toEqual([
"mandatory",
"optional",
"optional",
]);
const result = await widgets[0].load(
context([PERMS.NEWS_VIEW]),
new AbortController().signal,
);
expect(result).toMatchObject({
ok: true,
data: { drafts: 2, scheduled: 1 },
});
});
});
@@ -0,0 +1,69 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
type HousekeepingWorkItem,
} from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries";
type ContentInboxKind = "publication" | "attention";
function time(value: string | null | undefined) {
if (!value) return null;
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return null;
const ageMs = Math.max(0, Date.now() - timestamp);
return {
occurredAt: new Date(timestamp).toISOString(),
ageMs,
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
};
}
export async function loadContentInboxItems(
kind: ContentInboxKind,
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<readonly HousekeepingWorkItem[]> {
if (signal.aborted) throw new Error("aborted Content inbox");
const definitions =
kind === "publication"
? ([
["content.editorial.articles", PERMS.NEWS_VIEW, "content.publication"],
] as const)
: ([
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
["content.engagement.polls", PERMS.POLLS_VIEW, "content.attention"],
] as const);
const items: HousekeepingWorkItem[] = [];
for (const [routeId, permission, sourceId] of definitions) {
const result = await contentQuery.run(context, {
routeId,
list: { pageSize: 25, offset: 0 },
});
if (!result.ok) continue;
for (const item of result.data.items) {
const date = time(item.updatedAt);
if (!date || !item.href) continue;
items.push({
sourceId,
itemId: item.id,
deduplicationKey: sourceId + ":" + routeId + ":" + item.id,
domain: "content",
capability: anyCapability(permission),
severity: item.status === "failed" ? "warning" : "info",
priority: item.status === "failed" ? "high" : "normal",
...date,
state: item.status ?? "ready",
titleKey: "pages.housekeeping.items.content",
context: { title: item.title },
href: item.href as `/ase/${string}`,
actions: [],
});
if (items.length >= 25) return items;
}
}
return items;
}
@@ -0,0 +1,99 @@
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingInboxSource,
type HousekeepingWorkItem,
ok,
} from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
export const CONTENT_INBOX_SOURCE_IDS = [
"content.publication",
"content.attention",
] as const;
type ContentInboxLoader = (
context: HousekeepingCapabilityContext,
signal: AbortSignal,
) => Promise<readonly HousekeepingWorkItem[]>;
export interface ContentInboxAdapters {
readonly publication: ContentInboxLoader;
readonly attention: ContentInboxLoader;
}
function createSource(
id: (typeof CONTENT_INBOX_SOURCE_IDS)[number],
capability: CapabilityRequirement,
load: ContentInboxLoader,
): HousekeepingInboxSource {
return {
id,
owner: "content",
capability,
async getItems(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
const items = await load(context, signal);
return ok(
{
availability: "available" as const,
items: items
.filter(
(item) =>
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, 25),
},
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createContentInboxSources(
adapters: ContentInboxAdapters,
): readonly HousekeepingInboxSource[] {
return [
createSource(
"content.publication",
anyCapability(PERMS.NEWS_VIEW),
adapters.publication,
),
createSource(
"content.attention",
anyCapability(
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PAGES_VIEW,
PERMS.BANNERS_VIEW,
),
adapters.attention,
),
];
}
export const CONTENT_INBOX_SOURCES = createContentInboxSources({
async publication(context, signal) {
const { loadContentInboxItems } = await import("./inbox-production");
return loadContentInboxItems("publication", context, signal);
},
async attention(context, signal) {
const { loadContentInboxItems } = await import("./inbox-production");
return loadContentInboxItems("attention", context, signal);
},
});
@@ -3,6 +3,10 @@ import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
import { CONTENT_INBOX_SOURCES } from "./inbox";
import { CONTENT_ROUTES } from "./routes";
import { CONTENT_SEARCH_PROVIDERS } from "./search";
import { CONTENT_WIDGETS } from "./widgets";
export const contentManifest = {
id: "content",
@@ -26,8 +30,8 @@ export const contentManifest = {
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
routes: CONTENT_ROUTES,
searchProviders: CONTENT_SEARCH_PROVIDERS,
inboxSources: CONTENT_INBOX_SOURCES,
widgets: CONTENT_WIDGETS,
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,28 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
export function ContentBrandPage({ context, result, routeId }: ContentPageProps) {
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
{routeId === "content.brand.theme" ? <>
<ContentCommandForm commandId="content.brand.theme.update" buttonLabel="Save theme values" input={{}} fields={[{ name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason />
<ContentCommandForm commandId="content.brand.theme.apply-preset" buttonLabel="Apply preset" input={{}} fields={[{ name: "preset", label: "Preset", type: "text", required: true, maxLength: 100 }]} requiresReason />
<ContentCommandForm commandId="content.brand.theme.custom-change" buttonLabel="Save custom theme" input={{ action: "update" }} fields={[{ name: "id", label: "Theme ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 100 }, { name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason />
<ContentCommandForm commandId="content.brand.theme.apply-custom" buttonLabel="Apply custom theme" input={{}} fields={[{ name: "id", label: "Theme ID", type: "identifier", required: true }]} requiresReason />
</> : null}
{routeId === "content.brand.favicon" ? <>
<ContentCommandForm commandId="content.brand.favicon.save" buttonLabel="Save favicon" input={{}} fields={[{ name: "file", label: "Favicon", type: "file", required: true }]} requiresReason />
<ContentCommandForm commandId="content.brand.favicon.delete" buttonLabel="Delete favicon" input={{}} requiresReason />
<ContentCommandForm commandId="content.brand.logo.save" buttonLabel="Save logo" input={{}} fields={[{ name: "file", label: "Logo", type: "file", required: true }]} requiresReason />
</> : null}
</div> : null;
return <ContentPageFrame title="Brand" description="Manage theme, favicon, and logo assets." result={result} forms={forms} />;
}
export async function renderContentBrandPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.brand.theme", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
return <ContentBrandPage context={input.context} result={result} routeId={routeId} />;
}
@@ -0,0 +1,208 @@
"use client";
import { useActionState } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
export interface ContentCommandField {
readonly name: string;
readonly label: string;
readonly type:
| "identifier"
| "json"
| "text"
| "textarea"
| "number"
| "checkbox"
| "select"
| "file";
readonly required?: boolean;
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly defaultValue?: string | number;
readonly options?: readonly Readonly<{
value: string | number;
label: string;
}>[];
}
export interface ContentCommandSubmission {
readonly commandId: string;
readonly input: Readonly<Record<string, unknown>>;
readonly fields?: readonly ContentCommandField[];
readonly requiresReason?: boolean;
}
interface ContentCommandFormProps extends ContentCommandSubmission {
readonly buttonLabel: string;
}
function parseField(field: ContentCommandField, formData: FormData): unknown {
const rawValue = formData.get(field.name);
if (field.type === "checkbox") return rawValue === "on";
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
const raw = String(rawValue ?? "").normalize("NFC").trim();
if (field.type === "number") {
const value = Number(raw);
if (!Number.isSafeInteger(value)) return 0;
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
}
if (field.type === "select") {
const selected = field.options?.find(
(option) => String(option.value) === raw,
)?.value;
return selected ?? raw.slice(0, 500);
}
if (field.type === "json") {
try {
return JSON.parse(raw.slice(0, field.maxLength ?? 20_000));
} catch {
return null;
}
}
return raw.slice(0, field.maxLength ?? (field.type === "textarea" ? 20_000 : 500));
}
const initialState: HousekeepingResult<unknown> | null = null;
export async function submitContentCommandForm(
configuration: ContentCommandSubmission,
_previous: HousekeepingResult<unknown> | null,
formData: FormData,
): Promise<HousekeepingResult<unknown>> {
const input = {
...configuration.input,
...Object.fromEntries(
(configuration.fields ?? []).map((field) => [
field.name,
parseField(field, formData),
]),
),
};
const reason = String(formData.get("reason") ?? "")
.normalize("NFC")
.trim()
.slice(0, 1000);
const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command"
);
return executeHousekeepingCommand({
commandId: configuration.commandId,
input,
...(configuration.requiresReason ? { reason } : {}),
});
}
export function ContentCommandForm({
commandId,
buttonLabel,
input,
fields = [],
requiresReason = false,
}: ContentCommandFormProps) {
const [result, submit, pending] = useActionState(
submitContentCommandForm.bind(null, {
commandId,
input,
fields,
requiresReason,
}),
initialState,
);
return (
<form
action={submit}
data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
>
{fields.map((field) => (
<label
key={field.name}
htmlFor={`${commandId}-${field.name}`}
className="block text-sm"
>
{field.type === "checkbox" ? (
<>
<input
id={`${commandId}-${field.name}`}
name={field.name}
type="checkbox"
/> {field.label}
</>
) : field.type === "select" ? (
<>
{field.label}
<select
id={`${commandId}-${field.name}`}
name={field.name}
defaultValue={field.defaultValue}
required={field.required}
className="mt-1 block w-full"
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</>
) : field.type === "textarea" || field.type === "json" ? (
<>
{field.label}
<textarea
id={`${commandId}-${field.name}`}
name={field.name}
required={field.required}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
) : (
<>
{field.label}
<input
id={`${commandId}-${field.name}`}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "file"
? "file"
: "text"
}
defaultValue={
field.type === "file" ? undefined : field.defaultValue
}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
)}
</label>
))}
{requiresReason ? (
<label htmlFor={`${commandId}-reason`} className="block text-sm">
Reason
<textarea
id={`${commandId}-reason`}
name="reason"
required
maxLength={1000}
className="mt-1 block w-full"
/>
</label>
) : null}
<button type="submit" disabled={pending}>
{pending ? "Working..." : buttonLabel}
</button>
{result ? (
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
</p>
) : null}
</form>
);
}
@@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import type {
HousekeepingResult,
} from "../../../foundation/contracts";
import type { ContentQueryData } from "../queries/content-queries";
export interface ContentPageProps {
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly routeId: import("../routes").ContentRouteId | null;
}
export function ContentPageFrame({
title,
description,
result,
forms,
}: {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly forms?: ReactNode;
}) {
if (!result) {
return (
<section data-housekeeping-state="loading">
<h1>{title}</h1>
<p>{description}</p>
<p>Loading content…</p>
</section>
);
}
if (!result.ok) {
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
return (
<section data-housekeeping-state={state} role="alert">
<h1>{title}</h1>
<p>{result.error.messageKey}</p>
</section>
);
}
const state = result.data.items.length === 0 ? "empty" : "ready";
return (
<section data-housekeeping-state={state} className="space-y-4">
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
{forms}
{result.data.items.length === 0 ? (
<p>No matching content.</p>
) : (
<ul className="divide-y divide-[var(--admin-border)]">
{result.data.items.map((item) => (
<li key={item.id} className="py-2">
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
{item.status ? <span> — {item.status}</span> : null}
{item.description ? <p>{item.description}</p> : null}
</li>
))}
</ul>
)}
</section>
);
}
export function parseContentListInput(
searchParams: Readonly<Record<string, string | readonly string[] | undefined>>,
) {
const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
return {
search: first(searchParams.search),
pageSize: Number(first(searchParams.pageSize) ?? 25),
offset: Number(first(searchParams.offset) ?? 0),
};
}
@@ -0,0 +1,213 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
import { PERMS } from "@/lib/permission-slugs";
import {
fail,
type HousekeepingCapabilityContext,
ok,
} from "../../../foundation/contracts";
import { ContentBrandPage } from "./brand";
import { submitContentCommandForm } from "./content-command-form";
import { ContentEditorialPage } from "./editorial";
import { ContentEngagementPage } from "./engagement";
import { ContentHelpPage } from "./help";
import { ContentLocalizationPage } from "./localization";
import { ContentMediaPage } from "./media";
vi.mock("@/actions/housekeeping-command", () => ({
executeHousekeepingCommand: vi.fn(),
}));
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
const cases = [
["editorial", ContentEditorialPage, PERMS.NEWS_EDIT],
["media", ContentMediaPage, PERMS.PAGES_EDIT],
["engagement", ContentEngagementPage, PERMS.EVENTS_EDIT],
["help", ContentHelpPage, PERMS.PAGES_EDIT],
["brand", ContentBrandPage, PERMS.SETTINGS_EDIT],
["localization", ContentLocalizationPage, PERMS.SETTINGS_EDIT],
] as const;
describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
it("renders loading, forbidden, empty, and real ready states", () => {
const read = context(Object.values(PERMS));
const render = (result?: unknown) =>
renderToStaticMarkup(
<Component context={read} result={result as never} routeId={null} />,
);
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "forbidden")),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
ok(
{ kind, items: [], total: 0, partialDependencies: [] },
"empty",
),
),
).toContain('data-housekeeping-state="empty"');
const ready = render(
ok(
{
kind,
items: [
{
id: "18446744073709551615",
title: "Canonical item",
status: "ready",
href: `/ase/content/${kind}`,
},
],
total: 1,
partialDependencies: [],
},
"ready",
),
);
expect(ready).toContain('data-housekeeping-state="ready"');
expect(ready).toContain("Canonical item");
expect(ready).not.toContain("/admin");
expect(read.has(editPermission)).toBe(true);
});
});
describe("Content actionable form wiring", () => {
beforeEach(() => vi.clearAllMocks());
it("submits canonical identifiers and a bounded reason through the real server action", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "18446744073709551615" } }, "form"),
);
const formData = new FormData();
formData.set("id", "18446744073709551615");
formData.set("title", " Updated title ");
formData.set("reason", ` ${"r".repeat(1100)} `);
const result = await submitContentCommandForm(
{
commandId: "content.editorial.article.change",
input: { action: "update" },
fields: [
{ name: "id", label: "ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", maxLength: 255 },
],
requiresReason: true,
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.editorial.article.change",
input: {
action: "update",
id: "18446744073709551615",
title: "Updated title",
},
reason: "r".repeat(1000),
});
expect(result).toMatchObject({ ok: true });
});
it("parses structured JSON fields before dispatching real brand and localization forms", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { keyCount: 1 } }, "json-form"),
);
const formData = new FormData();
formData.set("data", '{ "welcome": "Hello" }');
await submitContentCommandForm(
{
commandId: "content.localization.cms.save",
input: { locale: "en" },
fields: [
{
name: "data",
label: "Translations",
type: "json",
maxLength: 500_000,
},
],
requiresReason: true,
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.localization.cms.save",
input: { locale: "en", data: { welcome: "Hello" } },
reason: "",
});
});
it("renders mutation forms only with the exact capability", () => {
const result = ok(
{
kind: "brand" as const,
items: [{ id: "theme", title: "Theme", status: "active" }],
total: 1,
partialDependencies: [],
},
"brand",
);
const readOnly = renderToStaticMarkup(
<ContentBrandPage
context={context([PERMS.SETTINGS_VIEW])}
result={result}
routeId="content.brand.theme"
/>,
);
const editor = renderToStaticMarkup(
<ContentBrandPage
context={context([PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT])}
result={result}
routeId="content.brand.theme"
/>,
);
expect(readOnly).not.toContain("content.brand.theme.update");
expect(editor).toContain("content.brand.theme.update");
expect(editor).toContain("<form");
});
it("does not render email template bodies or localization payloads in summaries", () => {
const html = renderToStaticMarkup(
<ContentHelpPage
context={context([PERMS.PAGES_VIEW])}
result={ok(
{
kind: "help",
items: [
{
id: "1",
title: "Welcome",
status: "active",
privatePayload: "secret template body",
},
],
total: 1,
partialDependencies: [],
},
"help",
)}
routeId="content.help.email-templates"
/>,
);
expect(html).toContain("Welcome");
expect(html).not.toContain("secret template body");
});
});
@@ -0,0 +1,79 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentEditorialPage({ context, result, routeId }: ContentPageProps) {
const canNews = context.has(PERMS.NEWS_EDIT);
const canPages = context.has(PERMS.PAGES_EDIT);
const canSettings = context.has(PERMS.SETTINGS_EDIT);
return (
<ContentPageFrame
title="Editorial content"
description="Manage articles, navigation, tags, and writable boxes."
result={result}
forms={
<div className="grid gap-3 lg:grid-cols-2">
{canNews && routeId?.includes("article") ? (
<ContentCommandForm
commandId="content.editorial.article.change"
buttonLabel="Save article"
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
fields={[
{ name: "id", label: "Article ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
{ name: "content", label: "Body", type: "textarea", required: true, maxLength: 100_000 },
]}
/>
) : null}
{canSettings && routeId === "content.editorial.articles" ? (
<ContentCommandForm
commandId="content.editorial.navigation.update"
buttonLabel="Save navigation"
input={{}}
fields={[{ name: "items", label: "Navigation JSON", type: "json", required: true, maxLength: 20_000 }]}
/>
) : null}
{canPages && routeId === "content.editorial.tags" ? (
<ContentCommandForm
commandId="content.editorial.tag.change"
buttonLabel="Save tag"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Tag ID", type: "identifier" },
{ name: "name", label: "Name", type: "text", required: true, maxLength: 100 },
]}
/>
) : null}
{canPages && routeId === "content.editorial.writeable-boxes" ? (
<ContentCommandForm
commandId="content.editorial.writeable-box.change"
buttonLabel="Save box"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Box ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
{ name: "content", label: "Content", type: "textarea", maxLength: 20_000 },
]}
/>
) : null}
</div>
}
/>
);
}
export async function renderContentEditorialPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.editorial.articles",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return <ContentEditorialPage context={input.context} result={result} routeId={routeId} />;
}
@@ -0,0 +1,34 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
export function ContentEngagementPage({ context, result, routeId }: ContentPageProps) {
const canEvents = context.has(PERMS.EVENTS_EDIT);
const canPolls = context.has(PERMS.POLLS_EDIT);
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
return <ContentPageFrame title="Engagement" description="Manage events, polls, and community prefixes." result={result} forms={<div className="grid gap-3 lg:grid-cols-2">
{canEvents && routeId === "content.engagement.event-types" ? <ContentCommandForm commandId="content.engagement.event-type.change" buttonLabel="Save event type" input={{ action: "update" }} fields={[{ name: "id", label: "Type ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 255 }]} /> : null}
{canEvents && routeId?.includes("event") && routeId !== "content.engagement.event-types" ? <>
<ContentCommandForm commandId="content.engagement.event.change" buttonLabel="Save event" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Event ID", type: "identifier" }, { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }]} />
<ContentCommandForm commandId="content.engagement.event-prize.change" buttonLabel="Save prize" input={{ action: "create" }} fields={[{ name: "eventId", label: "Event ID", type: "identifier", required: true }, { name: "prize", label: "Prize", type: "text", required: true, maxLength: 255 }]} />
<ContentCommandForm commandId="content.engagement.event-winner.add" buttonLabel="Add winner" input={{}} fields={[{ name: "eventId", label: "Event ID", type: "identifier", required: true }, { name: "userId", label: "User ID", type: "identifier", required: true }]} />
</> : null}
{canPolls && routeId?.includes("poll") ? <>
<ContentCommandForm commandId="content.engagement.poll.change" buttonLabel="Save poll" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Poll ID", type: "identifier" }, { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }]} />
<ContentCommandForm commandId="content.engagement.poll-question.change" buttonLabel="Save question" input={{ action: "create" }} fields={[{ name: "pollId", label: "Poll ID", type: "identifier", required: true }, { name: "question", label: "Question", type: "text", required: true, maxLength: 500 }]} />
</> : null}
{canPrefixes && routeId === "content.engagement.prefixes" ? <>
<ContentCommandForm commandId="content.engagement.prefix.change" buttonLabel="Save prefix" input={{ action: "update" }} fields={[{ name: "id", label: "Prefix ID", type: "identifier" }, { name: "text", label: "Text", type: "text", required: true, maxLength: 64 }]} />
<ContentCommandForm commandId="content.engagement.prefix-blacklist.change" buttonLabel="Update blacklist" input={{ action: "add" }} fields={[{ name: "word", label: "Word", type: "text", required: true, maxLength: 255 }]} />
<ContentCommandForm commandId="content.engagement.prefix-settings.update" buttonLabel="Save prefix settings" input={{}} fields={[{ name: "enabled", label: "Enable prefixes", type: "checkbox" }]} />
</> : null}
</div>} />;
}
export async function renderContentEngagementPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.engagement.events", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
return <ContentEngagementPage context={input.context} result={result} routeId={routeId} />;
}
@@ -0,0 +1,19 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
export function ContentHelpPage({ context, result, routeId }: ContentPageProps) {
const forms = context.has(PERMS.PAGES_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
{routeId?.includes("question") ? <ContentCommandForm commandId="content.help.question.change" buttonLabel="Save help question" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Question ID", type: "identifier" }, { name: "name", label: "Question", type: "text", required: true, maxLength: 255 }, { name: "answer", label: "Answer", type: "textarea", required: true, maxLength: 20_000 }]} /> : null}
{routeId === "content.help.email-templates" ? <ContentCommandForm commandId="content.help.email-template.change" buttonLabel="Save email template" input={{ action: "update" }} fields={[{ name: "id", label: "Template ID", type: "identifier", required: true }, { name: "subject", label: "Subject", type: "text", required: true, maxLength: 255 }, { name: "body", label: "Body", type: "textarea", required: true, maxLength: 100_000 }]} /> : null}
</div> : null;
return <ContentPageFrame title="Help content" description="Manage help questions and email templates." result={result} forms={forms} />;
}
export async function renderContentHelpPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.help.questions", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
return <ContentHelpPage context={input.context} result={result} routeId={routeId} />;
}
@@ -0,0 +1,20 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
export function ContentLocalizationPage({ context, result, routeId }: ContentPageProps) {
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
{routeId === "content.localization.cms" ? <ContentCommandForm commandId="content.localization.cms.save" buttonLabel="Save CMS translations" input={{}} fields={[{ name: "locale", label: "Locale", type: "text", required: true, maxLength: 16 }, { name: "data", label: "Translation JSON", type: "json", required: true, maxLength: 500_000 }]} requiresReason /> : null}
{routeId === "content.localization.client" ? <ContentCommandForm commandId="content.localization.client.save" buttonLabel="Save client translations" input={{}} fields={[{ name: "fileId", label: "Translation file", type: "text", required: true, maxLength: 100 }, { name: "data", label: "Translation JSON", type: "json", required: true, maxLength: 500_000 }]} requiresReason /> : null}
{routeId === "content.localization.emulator" ? <ContentCommandForm commandId="content.localization.emulator.save" buttonLabel="Save emulator translation" input={{}} fields={[{ name: "key", label: "Key", type: "text", required: true, maxLength: 255 }, { name: "value", label: "Value", type: "textarea", required: true, maxLength: 20_000 }]} requiresReason /> : null}
</div> : null;
return <ContentPageFrame title="Localization" description="Manage CMS, client, and emulator translation stores." result={result} forms={forms} />;
}
export async function renderContentLocalizationPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.localization.overview", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
return <ContentLocalizationPage context={input.context} result={result} routeId={routeId} />;
}
@@ -0,0 +1,32 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
export function ContentMediaPage({ context, result, routeId }: ContentPageProps) {
const canPages = context.has(PERMS.PAGES_EDIT);
const canBanners = context.has(PERMS.BANNERS_EDIT);
return (
<ContentPageFrame
title="Media"
description="Manage photos, uploaded media, banners, and advertisements."
result={result}
forms={<div className="grid gap-3 lg:grid-cols-2">
{canPages && routeId === "content.media.photos" ? <ContentCommandForm commandId="content.media.photo.delete" buttonLabel="Delete photo" input={{}} fields={[{ name: "id", label: "Photo ID", type: "identifier", required: true }]} /> : null}
{canPages && routeId === "content.media.library" ? <>
<ContentCommandForm commandId="content.media.asset.upload" buttonLabel="Upload media" input={{}} fields={[{ name: "file", label: "Media file", type: "file", required: true }]} />
<ContentCommandForm commandId="content.media.asset.delete" buttonLabel="Delete media" input={{}} fields={[{ name: "filename", label: "Filename", type: "text", required: true, maxLength: 255 }]} />
</> : null}
{canBanners && routeId === "content.media.banners" ? <ContentCommandForm commandId="content.media.banner.change" buttonLabel="Save banner" input={{ action: "update" }} fields={[{ name: "id", label: "Banner ID", type: "identifier" }, { name: "title", label: "Title", type: "text", maxLength: 255 }]} /> : null}
{canPages && routeId?.includes("ad") ? <ContentCommandForm commandId="content.media.ad.change" buttonLabel="Save advertisement" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Advertisement ID", type: "identifier" }, { name: "image", label: "Image path", type: "text", required: true, maxLength: 500 }, { name: "url", label: "Destination", type: "text", maxLength: 1000 }]} /> : null}
</div>}
/>
);
}
export async function renderContentMediaPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.media.photos", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
return <ContentMediaPage context={input.context} result={result} routeId={routeId} />;
}
@@ -0,0 +1,348 @@
import "server-only";
import { type ContentRouteId, contentRouteGroup } from "../routes";
import type {
ContentQueryData,
ContentQueryItem,
NormalizedContentQueryInput,
} from "./content-queries";
type RawRow = Readonly<Record<string, unknown>>;
interface QueryDefinition {
readonly statement: string;
readonly href: string;
readonly appendId?: boolean;
}
export const CONTENT_QUERY_DEFINITIONS = {
"content.editorial.articles": {
statement:
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
href: "/ase/content/editorial/articles/",
appendId: true,
},
"content.editorial.article-detail": {
statement:
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
href: "/ase/content/editorial/articles/",
appendId: true,
},
"content.editorial.tags": {
statement:
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name LIMIT 500",
href: "/ase/content/editorial/tags",
},
"content.editorial.writeable-boxes": {
statement:
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id LIMIT 500",
href: "/ase/content/editorial/writeable-boxes",
},
"content.media.photos": {
statement:
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC LIMIT 500",
href: "/ase/content/media/photos",
},
"content.media.banners": {
statement:
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id LIMIT 500",
href: "/ase/content/media/banners",
},
"content.media.ads": {
statement:
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
href: "/ase/content/media/ads/",
appendId: true,
},
"content.media.ad-detail": {
statement:
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
href: "/ase/content/media/ads/",
appendId: true,
},
"content.engagement.events": {
statement:
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
href: "/ase/content/engagement/events/",
appendId: true,
},
"content.engagement.event-detail": {
statement:
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
href: "/ase/content/engagement/events/",
appendId: true,
},
"content.engagement.event-types": {
statement:
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name LIMIT 500",
href: "/ase/content/engagement/events/types",
},
"content.engagement.polls": {
statement:
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
href: "/ase/content/engagement/polls/",
appendId: true,
},
"content.engagement.poll-detail": {
statement:
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
href: "/ase/content/engagement/polls/",
appendId: true,
},
"content.engagement.prefixes": {
statement:
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC LIMIT 500",
href: "/ase/content/engagement/prefixes",
},
"content.help.questions": {
statement:
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
href: "/ase/content/help/questions/",
appendId: true,
},
"content.help.question-detail": {
statement:
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
href: "/ase/content/help/questions/",
appendId: true,
},
"content.help.email-templates": {
statement:
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name LIMIT 500",
href: "/ase/content/help/email-templates",
},
"content.localization.emulator": {
statement:
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key LIMIT 500",
href: "/ase/content/localization/emulator",
},
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
const EMPTY_ROUTES = new Set<ContentRouteId>([
"content.editorial.article-create",
"content.media.ad-create",
"content.engagement.event-create",
"content.engagement.poll-create",
"content.help.question-create",
]);
function rows(result: unknown): readonly RawRow[] {
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid Content query result");
}
return result[0] as readonly RawRow[];
}
function value(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : value == null ? fallback : String(value);
}
function updatedAt(value: unknown): string | null {
if (value == null) return null;
const parsed = value instanceof Date ? value : new Date(String(value));
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function response(
input: NormalizedContentQueryInput,
items: readonly ContentQueryItem[],
total = items.length,
): ContentQueryData {
const start = input.list.offset;
return {
kind: contentRouteGroup(input.routeId),
items: items.slice(start, start + input.list.pageSize),
total,
partialDependencies: [],
};
}
function matches(input: NormalizedContentQueryInput, item: ContentQueryItem): boolean {
const needle = input.list.search.toLocaleLowerCase();
return (
needle.length === 0 ||
item.title.toLocaleLowerCase().includes(needle) ||
item.description?.toLocaleLowerCase().includes(needle) === true
);
}
function mapRows(
input: NormalizedContentQueryInput,
definition: QueryDefinition,
rawRows: readonly RawRow[],
): readonly ContentQueryItem[] {
let items = rawRows.map((row) => {
const id = value(row.id);
if (!id) throw new Error("invalid Content identifier");
return {
id,
title: value(row.title, "Untitled content"),
description: value(row.description) || undefined,
status: value(row.status) || undefined,
updatedAt: updatedAt(row.updated_at),
href: definition.appendId ? definition.href + id : definition.href,
};
}).filter((item) => matches(input, item));
if (input.params.id) {
items = items.filter((item) => item.id === input.params.id);
}
return items;
}
async function databaseRoute(
input: NormalizedContentQueryInput,
definition: QueryDefinition,
): Promise<ContentQueryData> {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const items = mapRows(
input,
definition,
rows(await db.execute(sql.raw(definition.statement))),
);
return response(input, items, items.length);
}
async function mediaLibrary(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
const [{ readdir, stat }, { resolve }, { MEDIA_ROOT }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
import("@/lib/media-storage"),
]);
let names: string[];
try {
names = (await readdir(MEDIA_ROOT)).filter((name) =>
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return response(input, []);
throw error;
}
const entries = await Promise.all(
names.map(async (name) => ({ name, metadata: await stat(resolve(MEDIA_ROOT, name)) })),
);
entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
const items = entries.map(({ name, metadata }) => ({
id: name,
title: name,
description: String(metadata.size) + " bytes",
status: "stored",
updatedAt: metadata.mtime.toISOString(),
href: "/ase/content/media/library",
})).filter((item) => matches(input, item));
return response(input, items, items.length);
}
async function brand(input: NormalizedContentQueryInput): Promise<ContentQueryData> {
const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
import("@/lib/services/site-settings"),
import("@/lib/theme-custom-store"),
]);
if (input.routeId === "content.brand.favicon") {
const favicon = await siteSettings.get("cms_favicon", null);
return response(input, [{
id: "favicon",
title: favicon || "Default favicon",
status: favicon ? "custom" : "default",
href: "/ase/content/brand/favicon",
}]);
}
const [preset, customThemes] = await Promise.all([
siteSettings.get("theme_preset", "Atom (golden)"),
listCustomThemes(),
]);
return response(input, [{
id: "active-theme",
title: preset || "Atom (golden)",
status: "active",
href: "/ase/content/brand/theme",
}, ...customThemes.map((theme) => ({
id: theme.id,
title: theme.name,
status: "saved",
updatedAt: new Date(theme.createdAt).toISOString(),
href: "/ase/content/brand/theme",
}))]);
}
async function localizationFiles(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
if (input.routeId === "content.localization.client") {
const { CLIENT_TRANSLATION_FILES } = await import(
"@/lib/client-translation-files"
);
return response(input, CLIENT_TRANSLATION_FILES.map((file) => ({
id: file.id,
title: file.id,
description: file.relPath,
status: file.readOnly ? "read-only" : "editable",
href: "/ase/content/localization/client",
})));
}
const [{ readdir }, { join }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
]);
let locales: string[] = [];
try {
locales = (await readdir(join(process.cwd(), "src", "messages")))
.filter((name) => name.endsWith(".json"))
.map((name) => name.slice(0, -5))
.sort();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (input.routeId === "content.localization.overview") {
return response(input, [
{
id: "cms",
title: "CMS translations",
status: String(locales.length) + " locales",
href: "/ase/content/localization/cms",
},
{
id: "client",
title: "Client translations",
status: "configured sources",
href: "/ase/content/localization/client",
},
{
id: "emulator",
title: "Emulator translations",
status: "database store",
href: "/ase/content/localization/emulator",
},
]);
}
return response(input, locales.map((locale) => ({
id: locale,
title: locale,
status: "editable",
href: "/ase/content/localization/cms",
})));
}
export async function loadProductionContentQuery(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
if (input.routeId === "content.media.library") return mediaLibrary(input);
if (input.routeId.startsWith("content.brand.")) return brand(input);
if (
input.routeId === "content.localization.overview" ||
input.routeId === "content.localization.client" ||
input.routeId === "content.localization.cms"
) {
return localizationFiles(input);
}
const definition = (CONTENT_QUERY_DEFINITIONS as Partial<
Record<ContentRouteId, QueryDefinition>
>)[input.routeId];
if (!definition) throw new Error("missing Content query adapter");
return databaseRoute(input, definition);
}
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_ROUTE_IDS } from "../routes";
import {
type ContentQueryData,
type ContentQueryInput,
createContentQuery,
} from "./content-queries";
import { CONTENT_QUERY_DEFINITIONS } from "./content-queries-production";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
const ready: ContentQueryData = {
kind: "editorial",
items: [
{
id: "18446744073709551615",
title: "Published article",
status: "published",
href: "/ase/content/editorial/articles/18446744073709551615",
},
],
total: 1,
partialDependencies: [],
};
describe("Content query", () => {
it("never selects email template bodies into summary query results", () => {
const emailTemplates =
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
});
it("fails closed before production adapters are invoked", async () => {
const load = vi.fn(async () => ready);
const query = createContentQuery({ load });
const result = await query.run(context([]), {
routeId: "content.editorial.articles",
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(load).not.toHaveBeenCalled();
});
it("bounds list input, preserves canonical BIGINT strings, and returns real adapter data", async () => {
const load = vi.fn(async (_input: ContentQueryInput) => ready);
const query = createContentQuery({ load });
const result = await query.run(context([PERMS.NEWS_VIEW]), {
routeId: "content.editorial.articles",
list: {
search: " launch ",
pageSize: 1000,
offset: 999_999,
},
});
expect(load).toHaveBeenCalledWith({
routeId: "content.editorial.articles",
params: {},
list: { search: "launch", pageSize: 100, offset: 100_000 },
});
expect(result).toMatchObject({
ok: true,
data: {
items: [{ id: "18446744073709551615" }],
total: 1,
partialDependencies: [],
},
});
});
it("rejects a fake partial result and maps complete adapter failure", async () => {
const malformed = createContentQuery({
load: async () =>
({
kind: "media",
items: [],
total: 0,
partialDependencies: ["imaginary"],
}) as ContentQueryData,
});
const unavailable = createContentQuery({
load: async () => {
throw new Error("database unavailable");
},
});
expect(
await malformed.run(context([PERMS.PAGES_VIEW]), {
routeId: "content.media.library",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(
await unavailable.run(context([PERMS.PAGES_VIEW]), {
routeId: "content.media.library",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("has an authorized production query path for every Content route", async () => {
const load = vi.fn(async (input: ContentQueryInput) => ({
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
items: [],
total: 0,
partialDependencies: [],
}));
const query = createContentQuery({ load });
const all = context(Object.values(PERMS));
for (const routeId of CONTENT_ROUTE_IDS) {
const result = await query.run(all, { routeId });
expect(result.ok, routeId).toBe(true);
}
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
});
});
@@ -0,0 +1,149 @@
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
import {
type ContentRouteGroup,
type ContentRouteId,
contentRouteById,
contentRouteGroup,
} from "../routes";
export interface ContentQueryListInput {
readonly search?: string;
readonly pageSize?: number;
readonly offset?: number;
}
export interface ContentQueryInput {
readonly routeId: ContentRouteId;
readonly params?: Readonly<Record<string, string>>;
readonly list?: ContentQueryListInput;
}
export interface ContentQueryItem {
readonly id: string;
readonly title: string;
readonly status?: string;
readonly description?: string;
readonly href?: string;
readonly updatedAt?: string | null;
readonly privatePayload?: unknown;
}
export interface ContentQueryData {
readonly kind: ContentRouteGroup;
readonly items: readonly ContentQueryItem[];
readonly total: number;
readonly partialDependencies: readonly string[];
}
export interface NormalizedContentQueryInput {
readonly routeId: ContentRouteId;
readonly params: Readonly<Record<string, string>>;
readonly list: Readonly<{
search: string;
pageSize: number;
offset: number;
}>;
}
export interface ContentQueryAdapters {
load(input: NormalizedContentQueryInput): Promise<ContentQueryData>;
}
function boundedInteger(
value: unknown,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
return {
routeId: input.routeId,
params: Object.fromEntries(
Object.entries(input.params ?? {}).map(([key, value]) => [
key.normalize("NFC").trim().slice(0, 128),
value.normalize("NFC").trim().slice(0, 128),
]),
),
list: {
search: String(input.list?.search ?? "")
.normalize("NFC")
.trim()
.slice(0, 128),
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
},
};
}
function isValidData(
data: ContentQueryData,
input: NormalizedContentQueryInput,
): boolean {
if (
data.kind !== contentRouteGroup(input.routeId) ||
!Array.isArray(data.items) ||
!Number.isSafeInteger(data.total) ||
data.total < 0 ||
!Array.isArray(data.partialDependencies) ||
data.partialDependencies.length !== 0
) {
return false;
}
return data.items.every(
(item) =>
typeof item.id === "string" &&
item.id.length > 0 &&
typeof item.title === "string" &&
item.title.length > 0 &&
(item.href === undefined || isSafeHousekeepingHref(item.href)),
);
}
export function createContentQuery(
adapters: ContentQueryAdapters,
): HousekeepingQuery<ContentQueryInput, ContentQueryData> {
return {
id: "content.query",
owner: "content",
capability: contentRouteById("content.editorial.articles").capability,
async run(context, input) {
const route = contentRouteById(input.routeId);
const authorization = authorizeHousekeeping(context, route.capability);
if (!authorization.ok) return authorization;
const normalized = normalizeInput(input);
try {
const data = await adapters.load(normalized);
if (!isValidData(data, normalized)) {
throw new Error("invalid Content query data");
}
return ok(data, authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export const contentQuery = createContentQuery({
async load(input) {
const { loadProductionContentQuery } = await import(
"./content-queries-production"
);
return loadProductionContentQuery(input);
},
});
@@ -0,0 +1,31 @@
import type { HousekeepingRouteHandler } from "../../route-handlers";
import { renderContentBrandPage } from "./pages/brand";
import { renderContentEditorialPage } from "./pages/editorial";
import { renderContentEngagementPage } from "./pages/engagement";
import { renderContentHelpPage } from "./pages/help";
import { renderContentLocalizationPage } from "./pages/localization";
import { renderContentMediaPage } from "./pages/media";
import {
CONTENT_ROUTE_IDS,
type ContentRouteId,
contentRouteGroup,
} from "./routes";
function rendererFor(
routeId: ContentRouteId,
): HousekeepingRouteHandler["render"] {
const group = contentRouteGroup(routeId);
if (group === "editorial") return renderContentEditorialPage;
if (group === "media") return renderContentMediaPage;
if (group === "engagement") return renderContentEngagementPage;
if (group === "help") return renderContentHelpPage;
if (group === "brand") return renderContentBrandPage;
return renderContentLocalizationPage;
}
export const CONTENT_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze(
CONTENT_ROUTE_IDS.map((routeId) =>
Object.freeze({ routeId, render: rendererFor(routeId) }),
),
);
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import { contentMigrationEntries } from "../../migration/content";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
import { contentManifest } from "./manifest";
import {
CONTENT_ROUTE_GROUPS,
CONTENT_ROUTE_IDS,
CONTENT_ROUTES,
type ContentRouteId,
contentRouteGroup,
} from "./routes";
const expected = [
["content.editorial.articles", "/ase/content/editorial/articles", "editorial"],
[
"content.editorial.article-create",
"/ase/content/editorial/articles/new",
"editorial",
],
[
"content.editorial.article-detail",
"/ase/content/editorial/articles/:id",
"editorial",
],
["content.media.photos", "/ase/content/media/photos", "media"],
["content.media.library", "/ase/content/media/library", "media"],
["content.media.banners", "/ase/content/media/banners", "media"],
["content.media.ads", "/ase/content/media/ads", "media"],
["content.media.ad-create", "/ase/content/media/ads/new", "media"],
["content.media.ad-detail", "/ase/content/media/ads/:id", "media"],
["content.engagement.events", "/ase/content/engagement/events", "engagement"],
[
"content.engagement.event-create",
"/ase/content/engagement/events/create",
"engagement",
],
[
"content.engagement.event-types",
"/ase/content/engagement/events/types",
"engagement",
],
[
"content.engagement.event-detail",
"/ase/content/engagement/events/:id",
"engagement",
],
["content.engagement.polls", "/ase/content/engagement/polls", "engagement"],
[
"content.engagement.poll-create",
"/ase/content/engagement/polls/create",
"engagement",
],
[
"content.engagement.poll-detail",
"/ase/content/engagement/polls/:id",
"engagement",
],
["content.help.questions", "/ase/content/help/questions", "help"],
[
"content.help.question-create",
"/ase/content/help/questions/new",
"help",
],
[
"content.help.question-detail",
"/ase/content/help/questions/:id",
"help",
],
["content.editorial.tags", "/ase/content/editorial/tags", "editorial"],
[
"content.engagement.prefixes",
"/ase/content/engagement/prefixes",
"engagement",
],
[
"content.editorial.writeable-boxes",
"/ase/content/editorial/writeable-boxes",
"editorial",
],
[
"content.help.email-templates",
"/ase/content/help/email-templates",
"help",
],
["content.brand.theme", "/ase/content/brand/theme", "brand"],
["content.brand.favicon", "/ase/content/brand/favicon", "brand"],
["content.localization.overview", "/ase/content/localization", "localization"],
[
"content.localization.client",
"/ase/content/localization/client",
"localization",
],
["content.localization.cms", "/ase/content/localization/cms", "localization"],
[
"content.localization.emulator",
"/ase/content/localization/emulator",
"localization",
],
] as const;
describe("Content routes", () => {
it("declares the six exact route groups", () => {
expect(CONTENT_ROUTE_GROUPS).toEqual([
"editorial",
"media",
"engagement",
"help",
"brand",
"localization",
]);
});
it("maps every migration row to its canonical route and group", () => {
expect(
CONTENT_ROUTES.map((route) => [
route.id,
route.href,
contentRouteGroup(route.id as ContentRouteId),
]),
).toEqual(expected);
expect(CONTENT_ROUTE_IDS).toEqual(expected.map(([id]) => id));
expect(CONTENT_ROUTES.map((route) => route.href).sort()).toEqual(
contentMigrationEntries
.filter((entry) => entry.targetPath !== null)
.map((entry) => entry.targetPath)
.sort(),
);
});
it("keeps manifest routes and real handlers in exact equality", () => {
expect(contentManifest.routes).toBe(CONTENT_ROUTES);
expect(contentManifest.routes.map((route) => route.id)).toEqual(
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
handler.routeId.startsWith("content."),
).map((handler) => handler.routeId),
);
expect(
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
manifest.routes.map((route) => route.id),
),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
});
});
@@ -0,0 +1,209 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type CanonicalHousekeepingHref,
type HousekeepingRouteDefinition,
} from "../../foundation/contracts";
export const CONTENT_ROUTE_GROUPS = [
"editorial",
"media",
"engagement",
"help",
"brand",
"localization",
] as const;
export type ContentRouteGroup = (typeof CONTENT_ROUTE_GROUPS)[number];
export const CONTENT_ROUTE_IDS = [
"content.editorial.articles",
"content.editorial.article-create",
"content.editorial.article-detail",
"content.media.photos",
"content.media.library",
"content.media.banners",
"content.media.ads",
"content.media.ad-create",
"content.media.ad-detail",
"content.engagement.events",
"content.engagement.event-create",
"content.engagement.event-types",
"content.engagement.event-detail",
"content.engagement.polls",
"content.engagement.poll-create",
"content.engagement.poll-detail",
"content.help.questions",
"content.help.question-create",
"content.help.question-detail",
"content.editorial.tags",
"content.engagement.prefixes",
"content.editorial.writeable-boxes",
"content.help.email-templates",
"content.brand.theme",
"content.brand.favicon",
"content.localization.overview",
"content.localization.client",
"content.localization.cms",
"content.localization.emulator",
] as const;
export type ContentRouteId = (typeof CONTENT_ROUTE_IDS)[number];
function contentRoute(
id: ContentRouteId,
href: CanonicalHousekeepingHref,
capabilities: readonly string[],
): HousekeepingRouteDefinition {
return {
id,
labelKey: `pages.housekeeping.routes.${id}`,
href,
capability: anyCapability(...capabilities),
};
}
export const CONTENT_ROUTES = [
contentRoute("content.editorial.articles", "/ase/content/editorial/articles", [
PERMS.NEWS_VIEW,
]),
contentRoute(
"content.editorial.article-create",
"/ase/content/editorial/articles/new",
[PERMS.NEWS_EDIT],
),
contentRoute(
"content.editorial.article-detail",
"/ase/content/editorial/articles/:id",
[PERMS.NEWS_VIEW],
),
contentRoute("content.media.photos", "/ase/content/media/photos", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.media.library", "/ase/content/media/library", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.media.banners", "/ase/content/media/banners", [
PERMS.BANNERS_VIEW,
]),
contentRoute("content.media.ads", "/ase/content/media/ads", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.media.ad-create", "/ase/content/media/ads/new", [
PERMS.PAGES_EDIT,
]),
contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [
PERMS.PAGES_VIEW,
]),
contentRoute(
"content.engagement.events",
"/ase/content/engagement/events",
[PERMS.EVENTS_VIEW],
),
contentRoute(
"content.engagement.event-create",
"/ase/content/engagement/events/create",
[PERMS.EVENTS_EDIT],
),
contentRoute(
"content.engagement.event-types",
"/ase/content/engagement/events/types",
[PERMS.EVENTS_EDIT],
),
contentRoute(
"content.engagement.event-detail",
"/ase/content/engagement/events/:id",
[PERMS.EVENTS_EDIT],
),
contentRoute(
"content.engagement.polls",
"/ase/content/engagement/polls",
[PERMS.POLLS_VIEW],
),
contentRoute(
"content.engagement.poll-create",
"/ase/content/engagement/polls/create",
[PERMS.POLLS_EDIT],
),
contentRoute(
"content.engagement.poll-detail",
"/ase/content/engagement/polls/:id",
[PERMS.POLLS_EDIT],
),
contentRoute("content.help.questions", "/ase/content/help/questions", [
PERMS.PAGES_VIEW,
]),
contentRoute(
"content.help.question-create",
"/ase/content/help/questions/new",
[PERMS.PAGES_EDIT],
),
contentRoute(
"content.help.question-detail",
"/ase/content/help/questions/:id",
[PERMS.PAGES_VIEW],
),
contentRoute("content.editorial.tags", "/ase/content/editorial/tags", [
PERMS.PAGES_VIEW,
]),
contentRoute(
"content.engagement.prefixes",
"/ase/content/engagement/prefixes",
[PERMS.PREFIXES_VIEW],
),
contentRoute(
"content.editorial.writeable-boxes",
"/ase/content/editorial/writeable-boxes",
[PERMS.PAGES_VIEW],
),
contentRoute(
"content.help.email-templates",
"/ase/content/help/email-templates",
[PERMS.PAGES_VIEW],
),
contentRoute("content.brand.theme", "/ase/content/brand/theme", [
PERMS.SETTINGS_VIEW,
]),
contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [
PERMS.SETTINGS_VIEW,
]),
contentRoute(
"content.localization.overview",
"/ase/content/localization",
[PERMS.SETTINGS_VIEW],
),
contentRoute(
"content.localization.client",
"/ase/content/localization/client",
[PERMS.SETTINGS_VIEW],
),
contentRoute("content.localization.cms", "/ase/content/localization/cms", [
PERMS.SETTINGS_VIEW,
]),
contentRoute(
"content.localization.emulator",
"/ase/content/localization/emulator",
[PERMS.SETTINGS_VIEW],
),
] as const satisfies readonly HousekeepingRouteDefinition[];
const routeGroups = new Map<ContentRouteId, ContentRouteGroup>(
CONTENT_ROUTE_IDS.map((routeId) => [
routeId,
routeId.split(".")[1] as ContentRouteGroup,
]),
);
export function contentRouteGroup(routeId: ContentRouteId): ContentRouteGroup {
const group = routeGroups.get(routeId);
if (!group) throw new Error(`unknown Content route: ${routeId}`);
return group;
}
export function contentRouteById(
routeId: ContentRouteId,
): (typeof CONTENT_ROUTES)[number] {
const route = CONTENT_ROUTES.find((candidate) => candidate.id === routeId);
if (!route) throw new Error(`unknown Content route: ${routeId}`);
return route;
}
@@ -0,0 +1,58 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries";
import type { ContentSearchCandidate } from "./search";
type ContentSearchKind = "articles" | "events" | "media" | "help";
const SEARCH_ROUTES = {
articles: [
["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)],
],
events: [
["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)],
],
media: [
["content.media.photos", anyCapability(PERMS.PAGES_VIEW)],
["content.media.library", anyCapability(PERMS.PAGES_VIEW)],
["content.media.banners", anyCapability(PERMS.BANNERS_VIEW)],
["content.media.ads", anyCapability(PERMS.PAGES_VIEW)],
],
help: [
["content.help.questions", anyCapability(PERMS.PAGES_VIEW)],
["content.help.email-templates", anyCapability(PERMS.PAGES_VIEW)],
],
} as const;
export async function loadContentSearchCandidates(
kind: ContentSearchKind,
context: HousekeepingCapabilityContext,
term: string,
limit: number,
): Promise<readonly ContentSearchCandidate[]> {
const candidates: ContentSearchCandidate[] = [];
for (const [routeId, capability] of SEARCH_ROUTES[kind]) {
const result = await contentQuery.run(context, {
routeId,
list: { search: term, pageSize: limit, offset: 0 },
});
if (!result.ok) continue;
for (const item of result.data.items) {
if (!item.href) continue;
candidates.push({
id: routeId + ":" + item.id,
title: item.title,
description: item.description ?? item.status,
href: item.href,
capability,
});
if (candidates.length >= limit) return candidates;
}
}
return candidates;
}
@@ -0,0 +1,131 @@
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingSearchProvider,
ok,
} from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
export const CONTENT_SEARCH_PROVIDER_IDS = [
"content.articles",
"content.events",
"content.media",
"content.help",
] as const;
export interface ContentSearchCandidate {
readonly id: string;
readonly title: string;
readonly description?: string;
readonly href: string;
readonly capability: CapabilityRequirement;
}
type ContentSearchLoader = (
context: HousekeepingCapabilityContext,
term: string,
limit: number,
) => Promise<readonly ContentSearchCandidate[]>;
export interface ContentSearchAdapters {
readonly articles: ContentSearchLoader;
readonly events: ContentSearchLoader;
readonly media: ContentSearchLoader;
readonly help: ContentSearchLoader;
}
function createProvider(
id: (typeof CONTENT_SEARCH_PROVIDER_IDS)[number],
capability: CapabilityRequirement,
load: ContentSearchLoader,
): HousekeepingSearchProvider {
return {
id,
owner: "content",
capability,
async search(context, input) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
const limit = Number.isFinite(input.limit)
? Math.min(25, Math.max(1, Math.trunc(input.limit)))
: 25;
const term = input.term.normalize("NFC").trim().slice(0, 128);
try {
const candidates = await load(context, term, limit);
return ok(
candidates
.filter(
(item) =>
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, limit)
.map((item) => ({
...item,
domain: "content" as const,
type: "entity" as const,
href: item.href as `/ase/${string}`,
})),
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createContentSearchProviders(
adapters: ContentSearchAdapters,
): readonly HousekeepingSearchProvider[] {
return [
createProvider(
"content.articles",
anyCapability(PERMS.NEWS_VIEW),
adapters.articles,
),
createProvider(
"content.events",
anyCapability(PERMS.EVENTS_VIEW),
adapters.events,
),
createProvider(
"content.media",
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
adapters.media,
),
createProvider(
"content.help",
anyCapability(PERMS.PAGES_VIEW),
adapters.help,
),
];
}
export const CONTENT_SEARCH_PROVIDERS = createContentSearchProviders({
async articles(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("articles", context, term, limit);
},
async events(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("events", context, term, limit);
},
async media(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("media", context, term, limit);
},
async help(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("help", context, term, limit);
},
});
@@ -0,0 +1,607 @@
import "server-only";
import { eq, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import {
db,
EmailTemplates,
Taggables,
Tags,
User,
WebsiteAds,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
WebsiteBanner,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventType,
WebsiteEventWinner,
WebsiteHelpCenterCategories,
WebsitePoll,
WebsitePollQuestion,
WebsiteWriteableBoxes,
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { canonicalize } from "@/lib/foundation/security";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
createEventSchema,
eventPrizeSchema,
eventTypeSchema,
eventWinnerSchema,
updateEventSchema,
} from "@/lib/validators/event";
import {
createPollSchema,
pollQuestionSchema,
updatePollSchema,
} from "@/lib/validators/poll";
import {
type ContentMutationContext,
ContentMutationFailure,
type ContentMutationOperation,
type ContentMutationSnapshot,
} from "./mutations";
type ContentDatabase = typeof db;
function database(transaction: unknown): ContentDatabase {
return (transaction ?? db) as ContentDatabase;
}
function record(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw validation();
}
return value as Record<string, unknown>;
}
function validation(): ContentMutationFailure {
return new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
function notFound(): ContentMutationFailure {
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
}
function text(value: unknown, maximum: number, required = false): string {
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
if (required && !normalized) throw validation();
return normalized;
}
function rawText(value: unknown, maximum = 100_000): string {
return String(value ?? "").normalize("NFC").slice(0, maximum);
}
function positiveBigInt(value: unknown): bigint {
const normalized = typeof value === "bigint" ? value.toString() : String(value ?? "").trim();
if (!/^[1-9]\d*$/u.test(normalized)) throw validation();
return BigInt(normalized);
}
function positiveInteger(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw validation();
return parsed;
}
function nonNegativeInteger(value: unknown, fallback = 0): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
function insertedId(result: unknown): string {
const value = (result as ResultSetHeader | undefined)?.insertId;
return positiveBigInt(value).toString();
}
async function activity(
context: ContentMutationContext,
action: string,
description: string,
targetType?: string,
targetId?: number,
): Promise<void> {
await logStaffActivity({
staffId: context.capability.actor.id,
action,
description,
...(targetType ? { targetType } : {}),
...(targetId === undefined ? {} : { targetId }),
});
}
async function uniqueSlug(
databaseConnection: ContentDatabase,
value: string,
): Promise<string> {
const base = slugify(value) || "article";
let candidate = base;
let suffix = 2;
for (;;) {
const [existing] = await databaseConnection
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, candidate))
.limit(1);
if (!existing) return candidate;
candidate = base + "-" + suffix;
suffix += 1;
}
}
async function articleChange(
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const title = text(data.title, 255, true);
const now = new Date();
const rawSlug = text(data.slug, 255);
const slug = await uniqueSlug(connection, rawSlug || title);
const [result] = (await connection.insert(WebsiteArticles).values({
slug,
title,
shortStory: text(data.shortStory ?? data.summary, 255),
fullStory: rawText(data.fullStory ?? data.content),
image: text(data.image, 255),
userId: context.capability.actor.id,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, title, slug }, output: { id } };
}
const id = positiveBigInt(data.id);
const [existing] = await connection
.select({ id: WebsiteArticles.id, title: WebsiteArticles.title, slug: WebsiteArticles.slug })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteArticleReactions).where(eq(WebsiteArticleReactions.articleId, id));
await connection.delete(WebsiteArticleComments).where(eq(WebsiteArticleComments.articleId, id));
await connection.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
return { before: { id: id.toString(), title: existing.title, slug: existing.slug }, after: null };
}
if (action !== "update") throw validation();
const title = text(data.title, 255, true);
const rawSlug = text(data.slug, 255);
const slug = rawSlug ? await uniqueSlug(connection, rawSlug) : existing.slug;
await connection.update(WebsiteArticles).set({
title,
slug,
shortStory: text(data.shortStory ?? data.summary, 255),
fullStory: rawText(data.fullStory ?? data.content),
image: text(data.image, 255),
updatedAt: new Date(),
}).where(eq(WebsiteArticles.id, id));
return {
before: { id: id.toString(), title: existing.title, slug: existing.slug },
after: { id: id.toString(), title, slug },
output: { id: id.toString() },
};
}
async function adChange(
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const image = text(data.image, 255, true);
const now = new Date();
const [result] = (await connection.insert(WebsiteAds).values({ image, createdAt: now, updatedAt: now })) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "ad_create", "Created advertisement #" + id + " (" + image + ")", "website_ad", Number(id));
return { before: null, after: { id, image }, output: { id } };
}
const id = positiveBigInt(data.id);
const [existing] = await connection.select({ id: WebsiteAds.id, image: WebsiteAds.image }).from(WebsiteAds).where(eq(WebsiteAds.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await activity(context, "ad_delete", "Deleted advertisement #" + id, "website_ad", Number(id));
return { before: { id: id.toString(), image: existing.image }, after: null };
}
if (action !== "update") throw validation();
const image = text(data.image, 255, true);
await connection.update(WebsiteAds).set({ image, updatedAt: new Date() }).where(eq(WebsiteAds.id, id));
await activity(context, "ad_update", "Updated advertisement #" + id + " (" + image + ")", "website_ad", Number(id));
return { before: { id: id.toString(), image: existing.image }, after: { id: id.toString(), image }, output: { id: id.toString() } };
}
function bannerValues(data: Record<string, unknown>) {
return {
title: text(data.title, 255, true),
subtitle: text(data.subtitle, 500),
image: text(data.image, 500),
link: text(data.link, 500),
color: text(data.color, 20),
isActive: Math.min(1, nonNegativeInteger(data.isActive, 1)),
sortOrder: nonNegativeInteger(data.sortOrder),
startDate: data.startDate ? text(data.startDate, 50) : null,
endDate: data.endDate ? text(data.endDate, 50) : null,
};
}
async function bannerChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const values = bannerValues(data);
const [result] = (await connection.insert(WebsiteBanner).values(values)) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, title: values.title }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsiteBanner.id, title: WebsiteBanner.title }).from(WebsiteBanner).where(eq(WebsiteBanner.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteBanner).where(eq(WebsiteBanner.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const values = bannerValues({ ...data, title: data.title ?? existing.title });
await connection.update(WebsiteBanner).set(values).where(eq(WebsiteBanner.id, id));
return { before: existing, after: { id, title: values.title }, output: { id: String(id) } };
}
async function eventTypeChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = eventTypeSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsiteEventType).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, name: parsed.data.name }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsiteEventType.id, name: WebsiteEventType.name }).from(WebsiteEventType).where(eq(WebsiteEventType.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteEventType).where(eq(WebsiteEventType.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const parsed = eventTypeSchema.partial().safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsiteEventType).set(values).where(eq(WebsiteEventType.id, id));
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
}
async function eventChange(
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = createEventSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsiteEvent).values({ ...parsed.data, hostUserId: context.capability.actor.id, updatedAt: new Date() });
const id = insertedId(result);
return { before: null, after: { id, title: parsed.data.title, status: parsed.data.status }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsiteEvent.id, title: WebsiteEvent.title, status: WebsiteEvent.status }).from(WebsiteEvent).where(eq(WebsiteEvent.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteEvent).where(eq(WebsiteEvent.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const parsed = updateEventSchema.safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsiteEvent).set({ ...values, updatedAt: new Date() }).where(eq(WebsiteEvent.id, id));
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
}
async function eventPrizeChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveInteger(data.id);
await connection.delete(WebsiteEventPrize).where(eq(WebsiteEventPrize.id, id));
return { before: { id }, after: null };
}
if (action !== "create") throw validation();
const parsed = eventPrizeSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsiteEventPrize).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, eventId: parsed.data.eventId, position: parsed.data.position }, output: { id } };
}
async function eventWinnerAdd(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const parsed = eventWinnerSchema.safeParse(record(input));
if (!parsed.success) throw validation();
const [result] = await database(transaction).insert(WebsiteEventWinner).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, eventId: parsed.data.eventId, userId: parsed.data.userId, position: parsed.data.position }, output: { id } };
}
async function pollChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = createPollSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsitePoll).values({ ...parsed.data, updatedAt: new Date() });
const id = insertedId(result);
return { before: null, after: { id, title: parsed.data.title, status: parsed.data.status }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsitePoll.id, title: WebsitePoll.title, status: WebsitePoll.status }).from(WebsitePoll).where(eq(WebsitePoll.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsitePoll).where(eq(WebsitePoll.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const parsed = updatePollSchema.safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsitePoll).set({ ...values, updatedAt: new Date() }).where(eq(WebsitePoll.id, id));
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
}
async function pollQuestionChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = pollQuestionSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsitePollQuestion).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, pollId: parsed.data.pollId, question: parsed.data.question }, output: { id } };
}
const id = positiveInteger(data.id);
if (action === "delete") {
await connection.delete(WebsitePollQuestion).where(eq(WebsitePollQuestion.id, id));
return { before: { id }, after: null };
}
if (action !== "update") throw validation();
const parsed = pollQuestionSchema.partial().safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsitePollQuestion).set(values).where(eq(WebsitePollQuestion.id, id));
return { before: { id }, after: { id, ...values }, output: { id: String(id) } };
}
async function tagChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const name = text(data.name, 255, true);
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
const now = new Date();
const [result] = (await connection.insert(Tags).values({ name, backgroundColor, createdAt: now, updatedAt: now })) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "tag_create", "Created tag " + name + " (#" + id + ")", "tag", Number(id));
return { before: null, after: { id, name, backgroundColor }, output: { id } };
}
const id = positiveBigInt(data.id);
if (action === "delete") {
await connection.delete(Taggables).where(eq(Taggables.tagId, id));
await connection.delete(Tags).where(eq(Tags.id, id));
await activity(context, "tag_delete", "Deleted tag #" + id, "tag", Number(id));
return { before: { id: id.toString() }, after: null };
}
if (action !== "update") throw validation();
const name = text(data.name, 255, true);
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
await connection.update(Tags).set({ name, backgroundColor, updatedAt: new Date() }).where(eq(Tags.id, id));
await activity(context, "tag_update", "Updated tag #" + id + " to " + name, "tag", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), name, backgroundColor }, output: { id: id.toString() } };
}
async function prefixChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const username = text(data.username, 255, true);
const [user] = await connection.select({ id: User.id }).from(User).where(eq(User.username, username)).limit(1);
if (!user) throw notFound();
await connection.execute(sql`INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) VALUES (${user.id}, ${text(data.text, 255, true)}, ${text(data.color, 32, true)}, ${text(data.icon, 255)}, ${text(data.effect, 255)}, ${Math.min(1, nonNegativeInteger(data.active, 1))})`);
return { before: null, after: { username, text: text(data.text, 255, true) } };
}
const id = positiveInteger(data.id);
if (action === "delete") {
await connection.execute(sql`DELETE FROM custom_prefixes WHERE id = ${id}`);
return { before: { id }, after: null };
}
if (action !== "update") throw validation();
const prefixText = text(data.text, 255, true);
const color = text(data.color, 32, true);
const icon = text(data.icon, 255);
const effect = text(data.effect, 255);
const active = Math.min(1, nonNegativeInteger(data.active, 1));
await connection.execute(sql`UPDATE custom_prefixes SET text = ${prefixText}, color = ${color}, icon = ${icon}, effect = ${effect}, active = ${active} WHERE id = ${id}`);
return { before: { id }, after: { id, text: prefixText, color, icon, effect, active }, output: { id: String(id) } };
}
async function prefixBlacklistChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "add") {
const word = text(data.word, 100, true);
await connection.execute(sql`INSERT INTO custom_prefix_blacklist (word) VALUES (${word})`);
return { before: null, after: { word } };
}
if (action !== "remove" && action !== "delete") throw validation();
const id = positiveInteger(data.id);
await connection.execute(sql`DELETE FROM custom_prefix_blacklist WHERE id = ${id}`);
return { before: { id }, after: null };
}
const PREFIX_SETTINGS = new Set(["enabled", "max_length", "min_rank", "min_rank_to_buy", "allow_colors", "allow_bold", "allow_italic", "default_color", "price_credits", "price_points", "points_type"]);
async function prefixSettingsUpdate(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const settings = record(record(input).settings ?? input);
const accepted: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(settings)) {
if (!PREFIX_SETTINGS.has(key)) continue;
const value = text(rawValue, 255);
accepted[key] = value;
await database(transaction).execute(sql`INSERT INTO custom_prefix_settings (\`key\`, \`value\`) VALUES (${key}, ${value}) ON DUPLICATE KEY UPDATE \`value\` = ${value}`);
}
return { before: null, after: { keys: Object.keys(accepted).sort() } };
}
async function helpQuestionChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveBigInt(data.id);
await connection.delete(WebsiteHelpCenterCategories).where(eq(WebsiteHelpCenterCategories.id, id));
await activity(context, "help_delete", "Deleted help-center entry #" + id, "help_center_category", Number(id));
return { before: { id: id.toString() }, after: null };
}
const name = text(data.name, 255, true);
const content = canonicalize(rawText(data.content ?? data.answer, 100_000));
if (!content) throw validation();
const values = {
name,
content,
position: Math.max(1, positiveInteger(data.position ?? 1)),
imageUrl: text(data.imageUrl, 255) || null,
buttonText: text(data.buttonText, 255) || null,
buttonUrl: text(data.buttonUrl, 255) || null,
buttonColor: text(data.buttonColor, 16) || "#eeb425",
buttonBorderColor: text(data.buttonBorderColor, 16) || "#facc15",
smallBox: Boolean(data.smallBox),
};
if (action === "create") {
const [result] = (await connection.insert(WebsiteHelpCenterCategories).values(values)) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "help_create", "Created help-center entry #" + id + " (" + name + ")", "help_center_category", Number(id));
return { before: null, after: { id, name }, output: { id } };
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
await connection.update(WebsiteHelpCenterCategories).set(values).where(eq(WebsiteHelpCenterCategories.id, id));
await activity(context, "help_update", "Updated help-center entry #" + id + " (" + name + ")", "help_center_category", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), name }, output: { id: id.toString() } };
}
async function writeableBoxChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveBigInt(data.id);
await connection.delete(WebsiteWriteableBoxes).where(eq(WebsiteWriteableBoxes.id, id));
await activity(context, "writeable_box_delete", "Deleted writeable box #" + id, "writeable_box", Number(id));
return { before: { id: id.toString() }, after: null };
}
if (action === "toggle") {
const id = positiveBigInt(data.id);
const isActive = data.next === "1" || data.next === 1 || data.next === true;
await connection.update(WebsiteWriteableBoxes).set({ isActive, updatedAt: new Date() }).where(eq(WebsiteWriteableBoxes.id, id));
await activity(context, "writeable_box_toggle", (isActive ? "Activated" : "Hid") + " writeable box #" + id, "writeable_box", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), isActive }, output: { id: id.toString() } };
}
const title = text(data.title, 255, true);
const values = {
title,
icon: text(data.icon, 255) || null,
content: rawText(data.content, 100_000),
position: nonNegativeInteger(data.position),
isActive: data.isActive === "1" || data.isActive === 1 || data.isActive === true,
updatedAt: new Date(),
};
if (action === "create") {
const now = new Date();
const [result] = (await connection.insert(WebsiteWriteableBoxes).values({ ...values, createdAt: now })) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "writeable_box_create", "Created writeable box " + title + " (#" + id + ")", "writeable_box", Number(id));
return { before: null, after: { id, title }, output: { id } };
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
await connection.update(WebsiteWriteableBoxes).set(values).where(eq(WebsiteWriteableBoxes.id, id));
await activity(context, "writeable_box_update", "Updated writeable box #" + id + " (" + title + ")", "writeable_box", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), title }, output: { id: id.toString() } };
}
async function emailTemplateChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveBigInt(data.id);
await connection.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
return { before: { id: id.toString() }, after: null };
}
const subject = text(data.subject, 255, true);
const body = rawText(data.body, 500_000);
if (!body) throw validation();
const values = { subject, body, variables: text(data.variables, 20_000) || null, isActive: Boolean(data.isActive) };
if (action === "create") {
const name = text(data.name, 255, true);
const [result] = (await connection.insert(EmailTemplates).values({ name, ...values })) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, name, subject, isActive: values.isActive }, output: { id } };
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
await connection.update(EmailTemplates).set(values).where(eq(EmailTemplates.id, id));
return { before: { id: id.toString() }, after: { id: id.toString(), subject, isActive: values.isActive }, output: { id: id.toString() } };
}
const DATABASE_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext, transaction: unknown) => Promise<ContentMutationSnapshot>>> = {
"article.change": articleChange,
"ad.change": adChange,
"banner.change": (input, _context, transaction) => bannerChange(input, transaction),
"event-type.change": (input, _context, transaction) => eventTypeChange(input, transaction),
"event.change": eventChange,
"event-prize.change": (input, _context, transaction) => eventPrizeChange(input, transaction),
"event-winner.add": (input, _context, transaction) => eventWinnerAdd(input, transaction),
"poll.change": (input, _context, transaction) => pollChange(input, transaction),
"poll-question.change": (input, _context, transaction) => pollQuestionChange(input, transaction),
"tag.change": tagChange,
"prefix.change": (input, _context, transaction) => prefixChange(input, transaction),
"prefix-blacklist.change": (input, _context, transaction) => prefixBlacklistChange(input, transaction),
"prefix-settings.update": (input, _context, transaction) => prefixSettingsUpdate(input, transaction),
"help-question.change": helpQuestionChange,
"writeable-box.change": writeableBoxChange,
"email-template.change": (input, _context, transaction) => emailTemplateChange(input, transaction),
};
export async function executeContentDatabaseMutation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot | null> {
const handler = DATABASE_HANDLERS[operation];
return handler ? handler(input, context, transaction) : null;
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type { ContentMutationFailure } from "./mutations";
const fsMocks = vi.hoisted(() => ({
mkdir: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock("node:fs/promises", () => fsMocks);
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
vi.mock("@/lib/db", () => ({
CameraWeb: {},
EmulatorSettings: {},
WebsiteSetting: {},
db: {},
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: { updateConfig: vi.fn() },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: {
get: vi.fn(),
reload: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn(),
}));
vi.mock("@/lib/admin/photo-files", () => ({
tryRemoveLocalPhotoFile: vi.fn(),
}));
const capability = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
const context = {
capability,
correlationId: "external-runtime",
legacy: false,
};
describe("Content external mutation runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
const file = new File(["image"], "photo.PNG", { type: "image/png" });
const snapshot = await executeContentExternalMutation(
"media.upload",
{ file },
context,
);
expect(snapshot).toMatchObject({
before: null,
after: { size: file.size, type: "image/png" },
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
});
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
expect(fsMocks.writeFile).toHaveBeenCalledWith(
expect.stringContaining("storage"),
expect.any(Buffer),
);
});
it("propagates a real storage failure before optimistic success", async () => {
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
const file = new File(["image"], "photo.png", { type: "image/png" });
await expect(
executeContentExternalMutation("media.upload", { file }, context),
).rejects.toThrow("disk offline");
});
it("maps normalized media traversal to validation instead of dependency failure", async () => {
await expect(
executeContentExternalMutation(
"media.delete",
{ filename: "../private.txt" },
context,
),
).rejects.toMatchObject({
code: "VALIDATION",
messageKey: "errors.housekeeping.validation",
} satisfies Partial<ContentMutationFailure>);
expect(fsMocks.unlink).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,463 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import * as JSONC from "jsonc-parser";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import {
ADMIN_NAV_CONFIG_KEY,
type AdminNavConfig,
serializeAdminNavConfig,
} from "@/lib/admin-nav-config";
import { getClientTranslationFile } from "@/lib/client-translation-files";
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
import { patchJson5 } from "@/lib/json5-patch";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
import {
deleteCustomThemeStore,
getCustomTheme,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
import {
type ContentMutationContext,
ContentMutationFailure,
type ContentMutationOperation,
type ContentMutationSnapshot,
} from "./mutations";
import { ContentCommittedExternalFailure } from "./mutations-production";
const MEDIA_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const FAVICON_TYPES = [
...MEDIA_TYPES,
"image/x-icon",
"image/svg+xml",
];
const CMS_LOCALES = new Set([
"en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro",
"hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru",
]);
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
const ADMIN_COLOR_KEYS = [
"admin_canvas",
"admin_surface",
"admin_text",
"admin_text_muted",
"admin_border",
"admin_sidebar_bg",
] as const;
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"] as const;
function validation(): ContentMutationFailure {
return new ContentMutationFailure("VALIDATION", "errors.housekeeping.validation");
}
function notFound(): ContentMutationFailure {
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
}
function record(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw validation();
return value as Record<string, unknown>;
}
function text(value: unknown, maximum: number, required = false): string {
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
if (required && !normalized) throw validation();
return normalized;
}
function jsonRecord(value: unknown): Record<string, unknown> {
if (typeof value === "string") {
try {
return record(JSON.parse(value));
} catch {
throw validation();
}
}
return record(value);
}
function fileValue(value: unknown): File {
if (
typeof value !== "object" ||
value === null ||
typeof (value as File).arrayBuffer !== "function" ||
typeof (value as File).name !== "string" ||
typeof (value as File).type !== "string" ||
typeof (value as File).size !== "number"
) {
throw validation();
}
return value as File;
}
async function writeWebsiteSetting(
key: string,
value: string,
comment: string,
): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file);
if (file.size <= 0 || file.size > 5 * 1024 * 1024 || !MEDIA_TYPES.includes(file.type)) throw validation();
await mkdir(MEDIA_ROOT, { recursive: true });
const extension = text(file.name.split(".").pop() ?? "png", 10, true).toLowerCase();
const name = Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
return {
before: null,
after: { name, size: file.size, type: file.type },
output: { name, url: "/api/media/" + name },
};
}
async function mediaDelete(input: unknown): Promise<ContentMutationSnapshot> {
const name = text(record(input).filename ?? record(input).name, 255, true);
let filePath: string;
try {
filePath = resolveMediaPath(name);
} catch {
throw validation();
}
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
try {
await unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
return { before: { name }, after: null };
}
async function photoDelete(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const id = Number(record(input).id);
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
const [row] = await db.select({ id: CameraWeb.id, url: CameraWeb.url }).from(CameraWeb).where(eq(CameraWeb.id, id)).limit(1);
if (!row) throw notFound();
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
await tryRemoveLocalPhotoFile(row.url);
await logStaffActivity({
staffId: context.capability.actor.id,
action: "photo_delete",
description: "Deleted camera photo #" + id,
targetType: "camera_web",
targetId: id,
});
return { before: { id, url: row.url }, after: null };
}
async function navigationUpdate(input: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const raw = data.items ?? data.config ?? data;
const config = jsonRecord(raw) as AdminNavConfig;
const value = serializeAdminNavConfig(config);
const beforeValue = await siteSettings.get(ADMIN_NAV_CONFIG_KEY, null);
await siteSettings.update(ADMIN_NAV_CONFIG_KEY, value);
return {
before: { configured: Boolean(beforeValue) },
after: {
configured: true,
groups: config.groupOrder?.length ?? 0,
hiddenGroups: config.hiddenGroups?.length ?? 0,
hiddenItems: config.hiddenItems?.length ?? 0,
},
output: { saved: true },
};
}
async function themeUpdate(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const source = jsonRecord(data.values ?? data);
const changed: string[] = [];
await db.transaction(async (transaction) => {
for (const mode of ["light", "dark"] as const) {
const bag: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
const databaseKey = settingKey(key, mode);
const raw = text(source[databaseKey], 255);
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
}
for (const [key, value] of Object.entries(ensureReadableThemeColors(bag))) {
const databaseKey = settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode);
await transaction.insert(WebsiteSetting).values({ key: databaseKey, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push(databaseKey);
}
}
const adminBag: Record<string, string> = {};
for (const key of ADMIN_COLOR_KEYS) {
const raw = text(source[key], 255);
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
}
for (const [key, value] of Object.entries(ensureReadableThemeColors(adminBag))) {
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push(key);
}
const radius = text(source.border_radius, 3);
if (/^\d{1,3}$/u.test(radius)) {
await transaction.insert(WebsiteSetting).values({ key: "border_radius", value: radius, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: radius } });
changed.push("border_radius");
}
const font = text(source.font_family, 100);
if (font in FONTS) {
await transaction.insert(WebsiteSetting).values({ key: "font_family", value: font, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: font } });
changed.push("font_family");
}
for (const key of HEADING_KEYS) {
const value = text(source[key], 3);
if (!/^\d{1,3}$/u.test(value)) continue;
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push(key);
}
if (Object.hasOwn(source, "custom_css")) {
const value = String(source.custom_css ?? "").normalize("NFC").slice(0, 20_000);
await transaction.insert(WebsiteSetting).values({ key: "custom_css", value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push("custom_css");
}
});
const snapshot: ContentMutationSnapshot = { before: null, after: { changedKeys: changed.sort() } };
try {
siteSettings.reload();
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_update", description: "Updated theme settings" });
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeApplyPreset(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const name = text(record(input).preset, 100, true);
const preset = PRESETS[name];
if (!preset) throw validation();
await db.transaction(async (transaction) => {
for (const [key, value] of presetSettings(preset)) {
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
}
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: name } });
});
const snapshot: ContentMutationSnapshot = { before: null, after: { preset: name }, output: { name } };
try {
siteSettings.reload();
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied theme preset " + name });
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeCustomChange(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const id = text(data.id, 100);
if (action === "delete") {
if (!id) throw validation();
const existing = await getCustomTheme(id);
if (!existing) throw notFound();
await deleteCustomThemeStore(id);
return { before: { id, name: existing.name }, after: null };
}
if (action !== "create" && action !== "update" && action !== "rename") throw validation();
const name = text(data.name, 100, true);
const settings = data.values ? Object.fromEntries(Object.entries(jsonRecord(data.values)).map(([key, value]) => [key, String(value)])) : await snapshotCurrentTheme();
const theme = await upsertCustomTheme(name, settings, id || undefined);
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Saved custom theme " + theme.name });
return { before: id ? { id } : null, after: { id: theme.id, name: theme.name }, output: { id: theme.id, name: theme.name } };
}
async function themeApplyCustom(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const id = text(record(input).id, 100, true);
const theme = await getCustomTheme(id);
if (!theme) throw notFound();
await db.transaction(async (transaction) => {
for (const [key, value] of Object.entries(theme.settings)) {
if (!value) continue;
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
}
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: theme.name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: theme.name } });
});
const snapshot: ContentMutationSnapshot = { before: null, after: { id, name: theme.name }, output: { name: theme.name } };
try {
siteSettings.reload();
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied custom theme " + theme.name });
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
function faviconExtension(type: string): string {
return ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg" } as Record<string, string>)[type] ?? "png";
}
async function removeStoredAsset(url: string | null | undefined, directory: string, prefix: string): Promise<void> {
if (!url?.startsWith(prefix)) return;
const name = url.slice(prefix.length);
if (!name || name.includes("..") || name.includes("/") || name.includes("\\")) return;
const filePath = path.resolve(directory, name);
if (!filePath.startsWith(directory + path.sep)) return;
try {
await unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file);
if (file.size <= 0 || file.size > 2 * 1024 * 1024 || !FAVICON_TYPES.includes(file.type)) throw validation();
const directory = resolveMediaPath("favicon");
const filename = "favicon-" + Date.now() + "." + faviconExtension(file.type);
const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_favicon");
await mkdir(directory, { recursive: true });
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
const url = "/api/media/favicon/" + filename;
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
try {
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function faviconDelete(): Promise<ContentMutationSnapshot> {
const oldUrl = await siteSettings.get("cms_favicon");
await removeStoredAsset(oldUrl, resolveMediaPath("favicon"), "/api/media/favicon/");
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: null };
try {
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file);
const extension = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
const directory = resolveMediaPath("logo");
const filename = "logo-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_logo");
await mkdir(directory, { recursive: true });
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
const url = "/api/media/logo/" + filename;
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
try {
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function cmsTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const locale = text(data.locale, 16, true);
if (!CMS_LOCALES.has(locale)) throw validation();
const translations = jsonRecord(data.data);
const filePath = path.join(process.cwd(), "src", "messages", locale + ".json");
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
return { before: null, after: { locale, keyCount: Object.keys(translations).length } };
}
async function clientTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const fileId = text(data.fileId, 100, true);
const translations = Object.fromEntries(Object.entries(jsonRecord(data.data)).map(([key, value]) => [key, String(value)]));
const file = getClientTranslationFile(fileId);
if (!file || file.readOnly) throw validation();
const absolutePath = path.join(process.cwd(), file.relPath);
const raw = await readFile(absolutePath, "utf-8");
if (file.format === "json") {
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
return { before: null, after: { fileId, keyCount: Object.keys(translations).length }, output: { commentsLost: false, unpatchedKeys: [] } };
}
const original: Record<string, string> = {};
const parsed = JSONC.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [key, value] of Object.entries(parsed)) original[key] = value == null ? "" : String(value);
}
const patched = patchJson5(raw, original, translations);
if (patched.unpatchedKeys.length === 0) {
await writeFile(absolutePath, patched.content, "utf-8");
} else {
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
}
return {
before: null,
after: { fileId, keyCount: Object.keys(translations).length },
output: { commentsLost: patched.unpatchedKeys.length > 0, unpatchedKeys: patched.unpatchedKeys },
};
}
async function emulatorTranslationSave(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const data = record(input);
const source = data.settings ? jsonRecord(data.settings) : data.key ? { [text(data.key, 255, true)]: text(data.value, 20_000) } : jsonRecord(data);
const entries = Object.entries(source).map(([key, value]) => [text(key, 255, true), String(value)] as const);
await db.transaction(async (transaction) => {
for (const [key, value] of entries) {
await transaction.insert(EmulatorSettings).values({ key, value }).onDuplicateKeyUpdate({ set: { value } });
}
});
const snapshot: ContentMutationSnapshot = { before: null, after: { keys: entries.map(([key]) => key).sort() } };
try {
const delivered = await rcon.updateConfig();
if (!context.legacy && !delivered) throw new Error("emulator configuration sync failed");
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext) => Promise<ContentMutationSnapshot>>> = {
"media.upload": (input) => mediaUpload(input),
"media.delete": (input) => mediaDelete(input),
"photo.delete": photoDelete,
"navigation.update": (input) => navigationUpdate(input),
"theme.update": themeUpdate,
"theme.apply-preset": themeApplyPreset,
"theme.custom-change": themeCustomChange,
"theme.apply-custom": themeApplyCustom,
"favicon.save": (input) => faviconSave(input),
"favicon.delete": () => faviconDelete(),
"logo.save": (input) => logoSave(input),
"translation.cms.save": (input) => cmsTranslationSave(input),
"translation.client.save": (input) => clientTranslationSave(input),
"translation.emulator.save": emulatorTranslationSave,
};
export async function executeContentExternalMutation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot | null> {
const handler = EXTERNAL_HANDLERS[operation];
return handler ? handler(input, context) : null;
}
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_MUTATION_OPERATIONS } from "./mutations";
import {
CONTENT_DATABASE_OPERATIONS,
CONTENT_EXTERNAL_OPERATIONS,
CONTENT_MIXED_OPERATIONS,
ContentCommittedExternalFailure,
createContentProductionMutationAdapter,
} from "./mutations-production";
const capability: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
const mutationContext = {
capability,
correlationId: "production-matrix",
legacy: false,
};
function dependencies() {
const transactionToken = { transaction: true };
const writeAudit = vi.fn(
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
);
const executeOperation = vi.fn(async (operation: string) => ({
before: { operation, state: "before" },
after: { operation, state: "after" },
output: { id: "18446744073709551615" },
}));
const transaction = vi.fn(async (run) => run(transactionToken));
return {
transactionToken,
writeAudit,
executeOperation,
transaction,
adapter: createContentProductionMutationAdapter({
transaction,
writeAudit,
executeOperation,
}),
};
}
describe("Content production mutation adapter", () => {
it("classifies every operation exactly once", () => {
const classified = [
...CONTENT_DATABASE_OPERATIONS,
...CONTENT_EXTERNAL_OPERATIONS,
...CONTENT_MIXED_OPERATIONS,
];
expect([...classified].sort()).toEqual(
[...CONTENT_MUTATION_OPERATIONS].sort(),
);
expect(new Set(classified).size).toBe(classified.length);
});
it("executes every production operation and serializes identifiers", async () => {
const deps = dependencies();
for (const operation of CONTENT_MUTATION_OPERATIONS) {
const snapshot = await deps.adapter.execute(
operation,
{ action: "update" },
mutationContext,
);
expect(snapshot.output?.id, operation).toBe("18446744073709551615");
}
expect(deps.executeOperation).toHaveBeenCalledTimes(
CONTENT_MUTATION_OPERATIONS.length,
);
});
it("commits database mutation and canonical success audit in one transaction", async () => {
const deps = dependencies();
await deps.adapter.execute(
"article.change",
{ action: "update", id: "18446744073709551615" },
mutationContext,
);
expect(deps.transaction).toHaveBeenCalledTimes(1);
expect(deps.executeOperation).toHaveBeenCalledWith(
"article.change",
expect.anything(),
mutationContext,
deps.transactionToken,
);
expect(deps.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "content.article.change",
outcome: "success",
domain: "content",
correlationId: "production-matrix",
}),
deps.transactionToken,
);
});
it("persists correlated intent and truthful outcome around external storage writes", async () => {
const deps = dependencies();
await deps.adapter.execute(
"translation.cms.save",
{ locale: "en", data: { welcome: "Hello" } },
mutationContext,
);
expect(deps.transaction).not.toHaveBeenCalled();
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
"intent",
"success",
]);
expect(
deps.writeAudit.mock.calls.every(
([entry]) => entry.correlationId === "production-matrix",
),
).toBe(true);
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain("Hello");
});
it("returns typed partial when database committed but the external effect failed", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(
new ContentCommittedExternalFailure({
before: { value: "/old.ico" },
after: { value: "/new.ico" },
}),
);
const snapshot = await deps.adapter.execute(
"favicon.save",
{ file: { name: "favicon.ico" } },
mutationContext,
);
expect(snapshot).toMatchObject({
before: { value: "/old.ico" },
after: { value: "/new.ico" },
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "partial",
});
});
it("records failure without optimistic after-state when external storage fails before commit", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(new Error("disk offline"));
await expect(
deps.adapter.execute(
"media.upload",
{ file: { name: "image.png" } },
mutationContext,
),
).rejects.toThrow("disk offline");
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "failure",
before: undefined,
after: undefined,
});
});
});
@@ -0,0 +1,212 @@
import type { AuditEntry } from "@/lib/services/audit";
import type {
ContentMutationAdapter,
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
export const CONTENT_DATABASE_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_EXTERNAL_OPERATIONS = [
"media.upload",
"media.delete",
"translation.cms.save",
"translation.client.save",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_MIXED_OPERATIONS = [
"photo.delete",
"navigation.update",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.emulator.save",
] as const satisfies readonly ContentMutationOperation[];
type TransactionToken = unknown;
export interface ContentProductionMutationDependencies {
transaction<T>(run: (transaction: TransactionToken) => Promise<T>): Promise<T>;
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
executeOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: TransactionToken,
): Promise<ContentMutationSnapshot>;
}
export class ContentCommittedExternalFailure extends Error {
constructor(readonly snapshot: ContentMutationSnapshot) {
super("Content database change committed but external effect failed");
this.name = "ContentCommittedExternalFailure";
}
}
function auditEntry(
operation: ContentMutationOperation,
context: ContentMutationContext,
outcome: NonNullable<AuditEntry["outcome"]>,
snapshot?: ContentMutationSnapshot,
): AuditEntry {
return {
userId: context.capability.actor.id,
action: "content." + operation,
target: "Content",
correlationId: context.correlationId,
domain: "content",
outcome,
before:
snapshot?.before === null || snapshot?.before === undefined
? undefined
: { ...snapshot.before },
after:
snapshot?.after === null || snapshot?.after === undefined
? undefined
: { ...snapshot.after },
};
}
function includesOperation(
operations: readonly ContentMutationOperation[],
operation: ContentMutationOperation,
): boolean {
return operations.includes(operation);
}
async function writeOutcomeOrMarkUnavailable(
dependencies: ContentProductionMutationDependencies,
operation: ContentMutationOperation,
context: ContentMutationContext,
snapshot: ContentMutationSnapshot,
outcome: "success" | "partial",
): Promise<ContentMutationSnapshot> {
try {
await dependencies.writeAudit(
auditEntry(operation, context, outcome, snapshot),
);
return snapshot;
} catch {
const completion = {
status: "partial" as const,
external: outcome === "partial" ? ("failed" as const) : ("completed" as const),
audit: "unavailable" as const,
};
return { ...snapshot, completion };
}
}
export function createContentProductionMutationAdapter(
dependencies: ContentProductionMutationDependencies,
): ContentMutationAdapter {
return {
async execute(operation, input, context) {
if (includesOperation(CONTENT_DATABASE_OPERATIONS, operation)) {
return dependencies.transaction(async (transaction) => {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
transaction,
);
await dependencies.writeAudit(
auditEntry(operation, context, "success", snapshot),
transaction,
);
return snapshot;
});
}
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
try {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
);
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"success",
);
} catch (error) {
if (
includesOperation(CONTENT_MIXED_OPERATIONS, operation) &&
error instanceof ContentCommittedExternalFailure
) {
const snapshot: ContentMutationSnapshot = {
...error.snapshot,
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
};
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"partial",
);
}
try {
await dependencies.writeAudit(
auditEntry(operation, context, "failure"),
);
} catch {
// Preserve the original dependency failure; the missing outcome is observable
// through the durable correlated intent.
}
throw error;
}
},
};
}
export const contentProductionMutationAdapter =
createContentProductionMutationAdapter({
async transaction(run) {
const { db } = await import("@/lib/db");
return db.transaction((transaction) => run(transaction));
},
async writeAudit(entry, transaction) {
const { logAudit } = await import("@/lib/services/audit");
await logAudit(entry, transaction as never);
},
async executeOperation(operation, input, context, transaction) {
const { executeContentMutationOperation } = await import(
"./mutations-runtime"
);
return executeContentMutationOperation(
operation,
input,
context,
transaction,
);
},
});
@@ -0,0 +1,35 @@
import "server-only";
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type {
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
import { ContentMutationFailure } from "./mutations";
export async function executeContentMutationOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: unknown,
): Promise<ContentMutationSnapshot> {
const databaseResult = await executeContentDatabaseMutation(
operation,
input,
context,
transaction,
);
if (databaseResult) return databaseResult;
const externalResult = await executeContentExternalMutation(
operation,
input,
context,
);
if (externalResult) return externalResult;
throw new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import {
CONTENT_MUTATION_OPERATIONS,
createContentMutationInvocation,
createContentMutationService,
} from "./mutations";
function context(
granted: readonly string[],
actorId = 42,
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: actorId, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
describe("Content mutation service authority", () => {
it("rehydrates server authority and rejects forged actor identity", async () => {
const adapter = { execute: vi.fn() };
const service = createContentMutationService(
adapter,
async () => context([PERMS.NEWS_EDIT], 42),
);
const result = await service.execute(
{ correlationId: "forged", expectedActorId: 7 },
"article.change",
{ action: "create", title: "Forged" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(adapter.execute).not.toHaveBeenCalled();
});
it("requires the exact operation ACL even after dispatcher authorization", async () => {
const adapter = { execute: vi.fn() };
const service = createContentMutationService(
adapter,
async () => context([PERMS.NEWS_EDIT]),
);
const result = await service.execute(
{ correlationId: "brand", expectedActorId: 42 },
"theme.update",
{},
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(adapter.execute).not.toHaveBeenCalled();
});
it("executes every declared operation through the real service boundary", async () => {
const execute = vi.fn(async (_operation, _input, mutationContext) => ({
before: null,
after: { actorId: mutationContext.capability.actor.id },
}));
const service = createContentMutationService(
{ execute },
async () => context(Object.values(PERMS)),
);
const invocation = createContentMutationInvocation(
{ id: 42 },
"operation-matrix",
);
for (const operation of CONTENT_MUTATION_OPERATIONS) {
const result = await service.execute(invocation, operation, {});
expect(result.ok, operation).toBe(true);
}
expect(execute).toHaveBeenCalledTimes(CONTENT_MUTATION_OPERATIONS.length);
});
it("maps adapter failures without exposing storage or template payloads", async () => {
const service = createContentMutationService(
{
execute: async () => {
throw new Error("C:\\private\\template.json: secret body");
},
},
async () => context([PERMS.SETTINGS_EDIT]),
);
const result = await service.execute(
{ correlationId: "redacted", expectedActorId: 42 },
"translation.cms.save",
{ data: { secret: "body" } },
);
expect(result).toMatchObject({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
});
expect(JSON.stringify(result)).not.toContain("secret body");
});
});
@@ -0,0 +1,210 @@
import { PERMS } from "@/lib/permission-slugs";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingErrorCode,
type HousekeepingPartialCompletion,
type HousekeepingResult,
ok,
} from "../../../foundation/contracts";
export const CONTENT_MUTATION_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"photo.delete",
"media.upload",
"media.delete",
"navigation.update",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.cms.save",
"translation.client.save",
"translation.emulator.save",
] as const;
export type ContentMutationOperation =
(typeof CONTENT_MUTATION_OPERATIONS)[number];
export interface ContentMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
readonly completion?: HousekeepingPartialCompletion;
}
export interface ContentMutationInvocation {
readonly correlationId: string;
readonly expectedActorId: number;
readonly legacy?: boolean;
}
export interface ContentMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly legacy: boolean;
}
export interface ContentMutationAdapter {
execute(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot>;
}
export interface ContentMutationService {
execute(
invocation: ContentMutationInvocation,
operation: ContentMutationOperation,
input: unknown,
): Promise<HousekeepingResult<ContentMutationSnapshot>>;
}
export class ContentMutationFailure extends Error {
constructor(
readonly code: HousekeepingErrorCode,
readonly messageKey: string,
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
) {
super(messageKey);
this.name = "ContentMutationFailure";
}
}
const OPERATION_PERMISSION = Object.freeze({
"article.change": PERMS.NEWS_EDIT,
"ad.change": PERMS.PAGES_EDIT,
"banner.change": PERMS.BANNERS_EDIT,
"event-type.change": PERMS.EVENTS_EDIT,
"event.change": PERMS.EVENTS_EDIT,
"event-prize.change": PERMS.EVENTS_EDIT,
"event-winner.add": PERMS.EVENTS_EDIT,
"poll.change": PERMS.POLLS_EDIT,
"poll-question.change": PERMS.POLLS_EDIT,
"photo.delete": PERMS.PAGES_EDIT,
"media.upload": PERMS.PAGES_EDIT,
"media.delete": PERMS.PAGES_EDIT,
"navigation.update": PERMS.SETTINGS_EDIT,
"tag.change": PERMS.PAGES_EDIT,
"prefix.change": PERMS.PREFIXES_EDIT,
"prefix-blacklist.change": PERMS.PREFIXES_EDIT,
"prefix-settings.update": PERMS.PREFIXES_EDIT,
"help-question.change": PERMS.PAGES_EDIT,
"writeable-box.change": PERMS.PAGES_EDIT,
"email-template.change": PERMS.PAGES_EDIT,
"theme.update": PERMS.SETTINGS_EDIT,
"theme.apply-preset": PERMS.SETTINGS_EDIT,
"theme.custom-change": PERMS.SETTINGS_EDIT,
"theme.apply-custom": PERMS.SETTINGS_EDIT,
"favicon.save": PERMS.SETTINGS_EDIT,
"favicon.delete": PERMS.SETTINGS_EDIT,
"logo.save": PERMS.SETTINGS_EDIT,
"translation.cms.save": PERMS.SETTINGS_EDIT,
"translation.client.save": PERMS.SETTINGS_EDIT,
"translation.emulator.save": PERMS.SETTINGS_EDIT,
} satisfies Record<ContentMutationOperation, string>);
export function contentMutationCapability(
operation: ContentMutationOperation,
) {
return anyCapability(OPERATION_PERMISSION[operation]);
}
export function createContentMutationService(
adapter: ContentMutationAdapter,
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
): ContentMutationService {
return {
async execute(invocation, operation, input) {
let capability: HousekeepingCapabilityContext;
try {
capability = await resolveCapabilityContext();
} catch {
return fail(
"UNAUTHENTICATED",
"errors.housekeeping.unauthenticated",
invocation.correlationId,
);
}
if (
capability.actor.id !== invocation.expectedActorId ||
!satisfiesCapability(capability, contentMutationCapability(operation))
) {
return fail(
"FORBIDDEN",
"errors.housekeeping.forbidden",
invocation.correlationId,
);
}
try {
const snapshot = await adapter.execute(operation, input, {
capability,
correlationId: invocation.correlationId,
legacy: invocation.legacy === true,
});
return ok(snapshot, invocation.correlationId, snapshot.completion);
} catch (error) {
if (error instanceof ContentMutationFailure) {
return fail(
error.code,
error.messageKey,
invocation.correlationId,
error.fieldErrors,
);
}
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
invocation.correlationId,
);
}
},
};
}
export function createContentMutationInvocation(
actor: { readonly id: number },
correlationId: string,
): ContentMutationInvocation {
return { correlationId, expectedActorId: actor.id, legacy: true };
}
const productionAdapter: ContentMutationAdapter = {
async execute(operation, input, context) {
const { contentProductionMutationAdapter } = await import(
"./mutations-production"
);
return contentProductionMutationAdapter.execute(operation, input, context);
},
};
export const contentMutationService = createContentMutationService(
productionAdapter,
async () => {
const { getHousekeepingCapabilityContext } = await import(
"../../../foundation/server-capability-context"
);
return getHousekeepingCapabilityContext();
},
);
@@ -0,0 +1,42 @@
import "server-only";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries";
type ContentWidgetKind = "editorial" | "media" | "localization";
async function total(
context: HousekeepingCapabilityContext,
routeId:
| "content.editorial.articles"
| "content.media.photos"
| "content.media.library"
| "content.localization.overview",
): Promise<number> {
const result = await contentQuery.run(context, {
routeId,
list: { pageSize: 1, offset: 0 },
});
return result.ok ? result.data.total : 0;
}
export async function loadContentWidget(
kind: ContentWidgetKind,
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<Readonly<Record<string, number>>> {
if (signal.aborted) throw new Error("aborted Content widget");
if (kind === "editorial") {
const articles = await total(context, "content.editorial.articles");
return { articles };
}
if (kind === "media") {
const [photos, library] = await Promise.all([
total(context, "content.media.photos"),
total(context, "content.media.library"),
]);
return { photos, library, items: photos + library };
}
const stores = await total(context, "content.localization.overview");
return { stores };
}
@@ -0,0 +1,94 @@
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingWidgetDefinition,
ok,
} from "../../foundation/contracts";
export const CONTENT_WIDGET_IDS = [
"content.editorial-summary",
"content.media-summary",
"content.localization-summary",
] as const;
type ContentWidgetLoader = (
context: HousekeepingCapabilityContext,
signal: AbortSignal,
) => Promise<Readonly<Record<string, number>>>;
export interface ContentWidgetAdapters {
readonly editorial: ContentWidgetLoader;
readonly media: ContentWidgetLoader;
readonly localization: ContentWidgetLoader;
}
function createWidget(
id: (typeof CONTENT_WIDGET_IDS)[number],
kind: "mandatory" | "optional",
capability: CapabilityRequirement,
load: ContentWidgetLoader,
): HousekeepingWidgetDefinition {
return {
id,
owner: "content",
kind,
capability,
async load(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
return ok(await load(context, signal), authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createContentWidgets(
adapters: ContentWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] {
return [
createWidget(
"content.editorial-summary",
"mandatory",
anyCapability(PERMS.NEWS_VIEW),
adapters.editorial,
),
createWidget(
"content.media-summary",
"optional",
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
adapters.media,
),
createWidget(
"content.localization-summary",
"optional",
anyCapability(PERMS.SETTINGS_VIEW),
adapters.localization,
),
];
}
export const CONTENT_WIDGETS = createContentWidgets({
async editorial(context, signal) {
const { loadContentWidget } = await import("./widgets-production");
return loadContentWidget("editorial", context, signal);
},
async media(context, signal) {
const { loadContentWidget } = await import("./widgets-production");
return loadContentWidget("media", context, signal);
},
async localization(context, signal) {
const { loadContentWidget } = await import("./widgets-production");
return loadContentWidget("localization", context, signal);
},
});
@@ -1,5 +1,6 @@
import "server-only";
import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
@@ -40,6 +41,7 @@ export function registerHousekeepingCommands<
}
const currentHousekeepingCommands = defineHousekeepingCommands(
...CONTENT_COMMANDS,
...USER_COMMANDS,
...COMMUNITY_COMMANDS,
...SUPPORT_COMMANDS,
@@ -4,6 +4,7 @@ import { join, posix } from "node:path";
import { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
@@ -208,6 +209,177 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/people/routes",
]),
],
[
"src/features/housekeeping/domains/content/commands/content-commands.ts",
new Set(["src/features/housekeeping/domains/content/services/mutations"]),
],
[
"src/features/housekeeping/domains/content/inbox-production.ts",
new Set([
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/inbox.ts",
new Set(["src/features/housekeeping/domains/content/inbox-production"]),
],
[
"src/features/housekeeping/domains/content/manifest.ts",
new Set([
"src/features/housekeeping/domains/content/inbox",
"src/features/housekeeping/domains/content/routes",
"src/features/housekeeping/domains/content/search",
"src/features/housekeeping/domains/content/widgets",
]),
],
[
"src/features/housekeeping/domains/content/pages/brand.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/content-command-form.tsx",
new Set(["src/actions/housekeeping-command"]),
],
[
"src/features/housekeeping/domains/content/pages/content-page-frame.tsx",
new Set([
"src/features/housekeeping/domains/content/queries/content-queries",
"src/features/housekeeping/domains/content/routes",
]),
],
[
"src/features/housekeeping/domains/content/pages/editorial.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/engagement.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/help.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/localization.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/media.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/queries/content-queries-production.ts",
new Set([
"src/features/housekeeping/domains/content/queries/content-queries",
"src/features/housekeeping/domains/content/routes",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/content/queries/content-queries.ts",
new Set([
"src/features/housekeeping/domains/content/queries/content-queries-production",
"src/features/housekeeping/domains/content/routes",
]),
],
[
"src/features/housekeeping/domains/content/route-handlers.ts",
new Set([
"src/features/housekeeping/domains/content/pages/brand",
"src/features/housekeeping/domains/content/pages/editorial",
"src/features/housekeeping/domains/content/pages/engagement",
"src/features/housekeeping/domains/content/pages/help",
"src/features/housekeeping/domains/content/pages/localization",
"src/features/housekeeping/domains/content/pages/media",
"src/features/housekeeping/domains/content/routes",
]),
],
[
"src/features/housekeeping/domains/content/search-production.ts",
new Set([
"src/features/housekeeping/domains/content/queries/content-queries",
"src/features/housekeeping/domains/content/search",
]),
],
[
"src/features/housekeeping/domains/content/search.ts",
new Set(["src/features/housekeeping/domains/content/search-production"]),
],
[
"src/features/housekeeping/domains/content/services/mutation-runtime-database.ts",
new Set([
"src/features/housekeeping/domains/content/services/mutations",
"src/lib/db",
"drizzle-orm",
"mysql2",
]),
],
[
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
new Set([
"src/features/housekeeping/domains/content/services/mutations",
"src/features/housekeeping/domains/content/services/mutations-production",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/content/services/mutations-production.ts",
new Set([
"src/features/housekeeping/domains/content/services/mutations",
"src/features/housekeeping/domains/content/services/mutations-runtime",
"src/lib/db",
]),
],
[
"src/features/housekeeping/domains/content/services/mutations-runtime.ts",
new Set([
"src/features/housekeeping/domains/content/services/mutation-runtime-database",
"src/features/housekeeping/domains/content/services/mutation-runtime-external",
"src/features/housekeeping/domains/content/services/mutations",
]),
],
[
"src/features/housekeeping/domains/content/services/mutations.ts",
new Set([
"src/features/housekeeping/domains/content/services/mutations-production",
]),
],
[
"src/features/housekeeping/domains/content/widgets-production.ts",
new Set([
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/widgets.ts",
new Set(["src/features/housekeeping/domains/content/widgets-production"]),
],
[
"src/features/housekeeping/domains/system/commands/system-commands.ts",
new Set(["src/features/housekeeping/domains/system/services/mutations"]),
@@ -316,6 +488,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"src/features/housekeeping/foundation/commands/bootstrap.ts",
new Set([
"src/features/housekeeping/domains/content/commands/content-commands",
"src/features/housekeeping/domains/people/commands/community-commands",
"src/features/housekeeping/domains/people/commands/moderation-commands",
"src/features/housekeeping/domains/people/commands/support-commands",
@@ -326,6 +499,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"src/features/housekeeping/route-handlers.ts",
new Set([
"src/features/housekeeping/domains/content/route-handlers",
"src/features/housekeeping/domains/people/route-handlers",
"src/features/housekeeping/domains/system/route-handlers",
]),
@@ -725,6 +899,12 @@ describe("housekeeping runtime import boundary", () => {
});
it("allows only approved domain vertical runtime edges", () => {
expect(
findHousekeepingImportBoundaryViolations(
'import { contentMutationService } from "../services/mutations";',
"src/features/housekeeping/domains/content/commands/content-commands.ts",
),
).toEqual([]);
expect(
findHousekeepingImportBoundaryViolations(
'import { peopleMutationService } from "../services/mutations";',
@@ -920,7 +1100,7 @@ describe("housekeeping foundation completion contracts", () => {
}
});
it("creates the real six-domain registry with People primary and System routes enabled", () => {
it("creates the real six-domain registry with People, Content, and System routes enabled", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
expect(registry.domains.map((domain) => domain.id)).toEqual([
@@ -933,7 +1113,7 @@ describe("housekeeping foundation completion contracts", () => {
]);
expect(
registry.domains
.filter((domain) => !["people", "system"].includes(domain.id))
.filter((domain) => !["people", "content", "system"].includes(domain.id))
.every((domain) => domain.routes.length === 0),
).toBe(true);
expect(
@@ -941,6 +1121,11 @@ describe("housekeeping foundation completion contracts", () => {
.find((domain) => domain.id === "people")
?.routes.map((route) => route.id),
).toEqual(PEOPLE_ROUTE_IDS);
expect(
registry.domains
.find((domain) => domain.id === "content")
?.routes.map((route) => route.id),
).toEqual(CONTENT_ROUTE_IDS);
expect(
registry.domains
.find((domain) => domain.id === "system")
@@ -27,6 +27,45 @@ const routeMocks = vi.hoisted(() => {
"domains.hotel.description": "Localized Hotel description",
"domains.system.title": "HK::system-title",
"domains.system.description": "Localized System description",
"routes.content.editorial.articles": "HK::content-editorial-articles",
"routes.content.editorial.article-create":
"HK::content-editorial-article-create",
"routes.content.editorial.article-detail":
"HK::content-editorial-article-detail",
"routes.content.media.photos": "HK::content-media-photos",
"routes.content.media.library": "HK::content-media-library",
"routes.content.media.banners": "HK::content-media-banners",
"routes.content.media.ads": "HK::content-media-ads",
"routes.content.media.ad-create": "HK::content-media-ad-create",
"routes.content.media.ad-detail": "HK::content-media-ad-detail",
"routes.content.engagement.events": "HK::content-engagement-events",
"routes.content.engagement.event-create":
"HK::content-engagement-event-create",
"routes.content.engagement.event-types":
"HK::content-engagement-event-types",
"routes.content.engagement.event-detail":
"HK::content-engagement-event-detail",
"routes.content.engagement.polls": "HK::content-engagement-polls",
"routes.content.engagement.poll-create":
"HK::content-engagement-poll-create",
"routes.content.engagement.poll-detail":
"HK::content-engagement-poll-detail",
"routes.content.help.questions": "HK::content-help-questions",
"routes.content.help.question-create": "HK::content-help-question-create",
"routes.content.help.question-detail": "HK::content-help-question-detail",
"routes.content.editorial.tags": "HK::content-editorial-tags",
"routes.content.engagement.prefixes": "HK::content-engagement-prefixes",
"routes.content.editorial.writeable-boxes":
"HK::content-editorial-writeable-boxes",
"routes.content.help.email-templates": "HK::content-help-email-templates",
"routes.content.brand.theme": "HK::content-brand-theme",
"routes.content.brand.favicon": "HK::content-brand-favicon",
"routes.content.localization.overview":
"HK::content-localization-overview",
"routes.content.localization.client": "HK::content-localization-client",
"routes.content.localization.cms": "HK::content-localization-cms",
"routes.content.localization.emulator":
"HK::content-localization-emulator",
"routes.system.access.permissions": "HK::system-access-permissions",
"routes.system.access.permission-detail":
"HK::system-access-permission-detail",
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { CONTENT_INBOX_SOURCES } from "../domains/content/inbox";
import { CONTENT_ROUTES } from "../domains/content/routes";
import { CONTENT_SEARCH_PROVIDERS } from "../domains/content/search";
import { CONTENT_WIDGETS } from "../domains/content/widgets";
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
import { PEOPLE_ROUTES } from "../domains/people/routes";
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
@@ -361,18 +365,32 @@ describe("housekeeping registry", () => {
expect(actual.routes).toEqual(
expected.id === "people"
? PEOPLE_ROUTES
: expected.id === "content"
? CONTENT_ROUTES
: expected.id === "system"
? SYSTEM_ROUTES
: [],
);
expect(actual.searchProviders).toEqual(
expected.id === "people" ? PEOPLE_SEARCH_PROVIDERS : [],
expected.id === "people"
? PEOPLE_SEARCH_PROVIDERS
: expected.id === "content"
? CONTENT_SEARCH_PROVIDERS
: [],
);
expect(actual.inboxSources).toEqual(
expected.id === "people" ? PEOPLE_INBOX_SOURCES : [],
expected.id === "people"
? PEOPLE_INBOX_SOURCES
: expected.id === "content"
? CONTENT_INBOX_SOURCES
: [],
);
expect(actual.widgets).toEqual(
expected.id === "people" ? PEOPLE_WIDGETS : [],
expected.id === "people"
? PEOPLE_WIDGETS
: expected.id === "content"
? CONTENT_WIDGETS
: [],
);
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
import { createHousekeepingRegistry } from "./foundation/registry";
@@ -19,6 +20,7 @@ describe("housekeeping route handlers", () => {
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
expect(handlerIds).toEqual([
...PEOPLE_ROUTE_IDS,
...CONTENT_ROUTE_IDS,
...SYSTEM_ROUTE_IDS,
]);
});
+6 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
@@ -18,4 +19,8 @@ export interface HousekeepingRouteHandler {
}
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze([...PEOPLE_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
Object.freeze([
...PEOPLE_ROUTE_HANDLERS,
...CONTENT_ROUTE_HANDLERS,
...SYSTEM_ROUTE_HANDLERS,
]);