Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -1,208 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { anyCapability, ok } from "./contracts";
|
||||
import { HousekeepingPageShell } from "./page/housekeeping-page-shell";
|
||||
import { HousekeepingPageState } from "./page/housekeeping-page-state";
|
||||
import type { HousekeepingPreferences } from "./preferences/schema";
|
||||
import { Favorites } from "./shell/favorites";
|
||||
import { HousekeepingShell } from "./shell/housekeeping-shell";
|
||||
import { OperationalInbox } from "./shell/operational-inbox";
|
||||
import { WidgetGrid } from "./shell/widget-grid";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const capability = anyCapability("admin.users.view");
|
||||
const preferences: HousekeepingPreferences = {
|
||||
schemaVersion: 1,
|
||||
pinnedRouteIds: ["people.users.list"],
|
||||
pinnedCommandIds: [],
|
||||
shortcutOrder: ["people.users.list"],
|
||||
enabledOptionalWidgetIds: [],
|
||||
widgetOrder: ["people.queue"],
|
||||
};
|
||||
const savePreferences = vi.fn(async (value: HousekeepingPreferences) =>
|
||||
ok(value, "saved"),
|
||||
);
|
||||
|
||||
const shellLabels = {
|
||||
skipToContent: "Skip to content",
|
||||
primaryNavigation: "Primary navigation",
|
||||
contextualNavigation: "Context navigation",
|
||||
command: "Search",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Partial",
|
||||
close: "Close",
|
||||
},
|
||||
};
|
||||
|
||||
describe("housekeeping accessibility contract", () => {
|
||||
it("renders the shell and page with labelled landmarks and a coherent heading hierarchy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId="operations"
|
||||
domains={[
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Daily operations",
|
||||
items: [],
|
||||
},
|
||||
]}
|
||||
labels={shellLabels}
|
||||
search={vi.fn()}
|
||||
>
|
||||
<HousekeepingPageShell title="Operations" description="Daily work">
|
||||
<h2>Inbox</h2>
|
||||
</HousekeepingPageShell>
|
||||
</HousekeepingShell>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-housekeeping-root="true"');
|
||||
expect(html).toContain("<header");
|
||||
expect(html).toContain('<nav aria-label="Primary navigation"');
|
||||
expect(html).toContain('<nav aria-label="Context navigation"');
|
||||
expect(html).toContain('<main id="housekeeping-content"');
|
||||
expect(html).toContain('aria-labelledby="housekeeping-page-title"');
|
||||
expect(html).toContain('<h1 id="housekeeping-page-title"');
|
||||
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
|
||||
expect(html).toContain(">Rank 6</span>");
|
||||
expect(html).toContain("focus-visible:not-sr-only");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", "status", "polite"],
|
||||
["empty", "status", "polite"],
|
||||
["partial", "status", "polite"],
|
||||
["error", "alert", "assertive"],
|
||||
["forbidden", "alert", "assertive"],
|
||||
["ready", "status", "polite"],
|
||||
] as const)(
|
||||
"gives the %s page state explicit semantic announcements",
|
||||
(state, role, live) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial"
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
) : (
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
);
|
||||
const html = renderToStaticMarkup(pageState);
|
||||
expect(html).toContain(`data-page-state="${state}"`);
|
||||
expect(html).toContain(`role="${role}"`);
|
||||
expect(html).toContain(`aria-live="${live}"`);
|
||||
},
|
||||
);
|
||||
|
||||
it("provides keyboard reorder instructions for favorites and widgets", () => {
|
||||
const favorites = renderToStaticMarkup(
|
||||
<Favorites
|
||||
preferences={preferences}
|
||||
routes={[
|
||||
{
|
||||
id: "people.users.list",
|
||||
labelKey: "pages.housekeeping.routes.people.users.list",
|
||||
href: "/ase/people/users",
|
||||
capability,
|
||||
},
|
||||
]}
|
||||
savePreferences={savePreferences}
|
||||
titles={{
|
||||
"pages.housekeeping.routes.people.users.list": "Users",
|
||||
}}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Could not save",
|
||||
reorderInstructions:
|
||||
"Press Space, then use the arrow keys to reorder.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const widgets = renderToStaticMarkup(
|
||||
<WidgetGrid
|
||||
preferences={preferences}
|
||||
result={{
|
||||
widgets: [{ id: "people.queue", data: { count: 1 } }],
|
||||
errors: [],
|
||||
}}
|
||||
savePreferences={savePreferences}
|
||||
titles={{ "people.queue": "People queue" }}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Partial widgets",
|
||||
error: "Could not save",
|
||||
reorderInstructions:
|
||||
"Press Space, then use the arrow keys to reorder.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(favorites).toContain(
|
||||
'id="housekeeping-favorites-reorder-instructions"',
|
||||
);
|
||||
expect(favorites).toContain(
|
||||
'aria-describedby="housekeeping-favorites-reorder-instructions"',
|
||||
);
|
||||
expect(favorites).toContain(">Users<");
|
||||
expect(favorites).not.toContain(
|
||||
">pages.housekeeping.routes.people.users.list<",
|
||||
);
|
||||
expect(widgets).toContain('id="housekeeping-widgets-reorder-instructions"');
|
||||
expect(widgets).toContain(
|
||||
'aria-describedby="housekeeping-widgets-reorder-instructions"',
|
||||
);
|
||||
expect(widgets).toContain(">People queue<");
|
||||
});
|
||||
|
||||
it("announces partial provider failures without exposing internal identifiers", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OperationalInbox
|
||||
response={{
|
||||
items: [],
|
||||
errors: [{ sourceId: "internal.provider", code: "TIMEOUT" }],
|
||||
correlationId: "private-correlation",
|
||||
}}
|
||||
labels={{
|
||||
title: "Inbox",
|
||||
domainFilter: "Domain",
|
||||
stateFilter: "State",
|
||||
priorityFilter: "Priority",
|
||||
all: "All",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Some data is unavailable",
|
||||
open: "Open",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('aria-live="assertive"');
|
||||
expect(html).not.toContain("internal.provider");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
expect(html).not.toContain("private-correlation");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { authorizeHousekeeping } from "./authorization";
|
||||
import { anyCapability, type HousekeepingCapabilityContext } from "./contracts";
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
rank = 7,
|
||||
): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => granted.includes(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => granted.includes(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("authorizeHousekeeping", () => {
|
||||
it("returns the authorized capability context", () => {
|
||||
const context = capabilityContext(["admin.users.view"]);
|
||||
|
||||
expect(
|
||||
authorizeHousekeeping(context, anyCapability("admin.users.view")),
|
||||
).toMatchObject({ ok: true, data: context });
|
||||
});
|
||||
|
||||
it("returns FORBIDDEN for a missing capability regardless of rank", () => {
|
||||
const result = authorizeHousekeeping(
|
||||
capabilityContext([], 999),
|
||||
anyCapability("admin.users.view"),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
messageKey: "errors.housekeeping.forbidden",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { satisfiesCapability } from "./capability-context";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "./contracts";
|
||||
import { fail, ok } from "./contracts";
|
||||
import { createCorrelationId } from "./correlation";
|
||||
|
||||
export function authorizeHousekeeping(
|
||||
context: HousekeepingCapabilityContext,
|
||||
requirement: CapabilityRequirement,
|
||||
): HousekeepingResult<HousekeepingCapabilityContext> {
|
||||
const correlationId = createCorrelationId();
|
||||
|
||||
return satisfiesCapability(context, requirement)
|
||||
? ok(context, correlationId)
|
||||
: fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||
import {
|
||||
runWithAuditIntent,
|
||||
writeIntent,
|
||||
writeOutcome,
|
||||
} from "./audit-envelope";
|
||||
|
||||
const auditEntry: AuditEntry = {
|
||||
userId: 7,
|
||||
action: "maintenance.run",
|
||||
target: "system",
|
||||
correlationId: "corr-command-7",
|
||||
domain: "system",
|
||||
reason: "Scheduled maintenance",
|
||||
};
|
||||
|
||||
function createWriter(): HousekeepingAuditWriter {
|
||||
return { write: vi.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
describe("audit command envelope", () => {
|
||||
it("writes an intent with the original correlation evidence", async () => {
|
||||
const writer = createWriter();
|
||||
|
||||
await writeIntent(writer, auditEntry);
|
||||
|
||||
expect(writer.write).toHaveBeenCalledWith(
|
||||
{ ...auditEntry, outcome: "intent" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes denied and failure outcomes as durable evidence", async () => {
|
||||
const writer = createWriter();
|
||||
|
||||
await writeOutcome(writer, auditEntry, "denied");
|
||||
await writeOutcome(writer, auditEntry, "failure");
|
||||
|
||||
expect(writer.write).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ ...auditEntry, outcome: "denied" },
|
||||
undefined,
|
||||
);
|
||||
expect(writer.write).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ ...auditEntry, outcome: "failure" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks the operation when intent persistence fails", async () => {
|
||||
const writer: HousekeepingAuditWriter = {
|
||||
write: vi.fn().mockRejectedValue(new Error("audit unavailable")),
|
||||
};
|
||||
const operation = vi.fn().mockResolvedValue("external operation result");
|
||||
|
||||
await expect(
|
||||
runWithAuditIntent(writer, auditEntry, operation),
|
||||
).rejects.toThrow("audit unavailable");
|
||||
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes a success outcome after the operation completes", async () => {
|
||||
const writer = createWriter();
|
||||
|
||||
await expect(
|
||||
runWithAuditIntent(writer, auditEntry, () => Promise.resolve("done")),
|
||||
).resolves.toBe("done");
|
||||
|
||||
expect(writer.write).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ ...auditEntry, outcome: "intent" },
|
||||
undefined,
|
||||
);
|
||||
expect(writer.write).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ ...auditEntry, outcome: "success" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a failure outcome before rethrowing an operation failure", async () => {
|
||||
const writer = createWriter();
|
||||
|
||||
await expect(
|
||||
runWithAuditIntent(writer, auditEntry, () =>
|
||||
Promise.reject(new Error("operation failed")),
|
||||
),
|
||||
).rejects.toThrow("operation failed");
|
||||
|
||||
expect(writer.write).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ ...auditEntry, outcome: "failure" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
it("preserves the original operation error when failure evidence rejects", async () => {
|
||||
const original = new Error("operation failed");
|
||||
const auditFailure = new Error("audit failure");
|
||||
const writer: HousekeepingAuditWriter = {
|
||||
write: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(auditFailure),
|
||||
};
|
||||
await expect(
|
||||
runWithAuditIntent(writer, auditEntry, () => Promise.reject(original)),
|
||||
).rejects.toBe(original);
|
||||
expect(
|
||||
(original as Error & { auditOutcomeError?: unknown }).auditOutcomeError,
|
||||
).toBe(auditFailure);
|
||||
});
|
||||
|
||||
it("throws a completed-operation error when success evidence rejects", async () => {
|
||||
const auditFailure = new Error("audit failure");
|
||||
const writer: HousekeepingAuditWriter = {
|
||||
write: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(auditFailure),
|
||||
};
|
||||
await expect(
|
||||
runWithAuditIntent(writer, auditEntry, () => Promise.resolve("done")),
|
||||
).rejects.toMatchObject({
|
||||
operationCompleted: true,
|
||||
operationResult: "done",
|
||||
auditOutcomeError: auditFailure,
|
||||
});
|
||||
});
|
||||
it("preserves a frozen operation error when failure evidence rejects", async () => {
|
||||
const original = Object.freeze(new Error("operation failed"));
|
||||
const writer: HousekeepingAuditWriter = {
|
||||
write: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("audit failure")),
|
||||
};
|
||||
await expect(
|
||||
runWithAuditIntent(writer, auditEntry, () => Promise.reject(original)),
|
||||
).rejects.toBe(original);
|
||||
});
|
||||
it("preserves a pathological rejection when failure evidence rejects", async () => {
|
||||
const { proxy, revoke } = Proxy.revocable(
|
||||
new Error("operation failed"),
|
||||
{},
|
||||
);
|
||||
revoke();
|
||||
const writer: HousekeepingAuditWriter = {
|
||||
write: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("audit failure")),
|
||||
};
|
||||
let rejected: unknown;
|
||||
try {
|
||||
await runWithAuditIntent(writer, auditEntry, () => Promise.reject(proxy));
|
||||
} catch (error) {
|
||||
rejected = error;
|
||||
}
|
||||
expect(rejected === proxy).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import type {
|
||||
AuditEntry,
|
||||
HousekeepingAuditTransaction,
|
||||
HousekeepingAuditWriter,
|
||||
} from "@/lib/services/audit";
|
||||
|
||||
type AuditOutcome = Exclude<NonNullable<AuditEntry["outcome"]>, "intent">;
|
||||
export class AuditOutcomePersistenceError<T> extends Error {
|
||||
readonly operationCompleted = true;
|
||||
|
||||
constructor(
|
||||
readonly operationResult: T,
|
||||
readonly auditOutcomeError: unknown,
|
||||
) {
|
||||
super("Operation completed but audit outcome could not be persisted");
|
||||
this.name = "AuditOutcomePersistenceError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeIntent(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<void> {
|
||||
await writer.write({ ...entry, outcome: "intent" }, transaction);
|
||||
}
|
||||
|
||||
export async function writeOutcome(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
outcome: AuditOutcome,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<void> {
|
||||
await writer.write({ ...entry, outcome }, transaction);
|
||||
}
|
||||
|
||||
export async function runWithAuditIntent<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
operation: () => Promise<T>,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<T> {
|
||||
await writeIntent(writer, entry, transaction);
|
||||
|
||||
let result: T;
|
||||
try {
|
||||
result = await operation();
|
||||
} catch (error) {
|
||||
try {
|
||||
await writeOutcome(writer, entry, "failure", transaction);
|
||||
} catch (auditOutcomeError) {
|
||||
try {
|
||||
if (error instanceof Error) {
|
||||
Object.defineProperty(error, "auditOutcomeError", {
|
||||
value: auditOutcomeError,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Enrichment is best-effort; always preserve the original rejection.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeOutcome(writer, entry, "success", transaction);
|
||||
} catch (auditOutcomeError) {
|
||||
throw new AuditOutcomePersistenceError(result, auditOutcomeError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import { housekeepingCommandRegistryReady } from "../../commands";
|
||||
import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands";
|
||||
import { MODERATION_COMMAND_IDS } from "../../domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMAND_IDS } from "../../domains/people/commands/support-commands";
|
||||
import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands";
|
||||
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
|
||||
import { anyCapability, ok } from "../contracts";
|
||||
import {
|
||||
defineHousekeepingCommands,
|
||||
registerHousekeepingCommands,
|
||||
} from "./bootstrap";
|
||||
import {
|
||||
getHousekeepingCommand,
|
||||
type HousekeepingCommand,
|
||||
registerHousekeepingCommand,
|
||||
} from "./registry";
|
||||
|
||||
describe("housekeeping command bootstrap", () => {
|
||||
it("preserves heterogeneous concrete input and output types", () => {
|
||||
const stringCommand: HousekeepingCommand<
|
||||
{ value: string },
|
||||
{ length: number }
|
||||
> = {
|
||||
id: "system.bootstrap.string-shape",
|
||||
owner: "system",
|
||||
risk: "safe",
|
||||
capability: anyCapability("admin.settings.view"),
|
||||
input: z.object({ value: z.string() }),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 1, windowMs: 1_000 },
|
||||
execute: async (context, input) =>
|
||||
ok({ length: input.value.length }, context.correlationId),
|
||||
};
|
||||
const numberCommand: HousekeepingCommand<
|
||||
{ count: number },
|
||||
{ doubled: number }
|
||||
> = {
|
||||
id: "system.bootstrap.number-shape",
|
||||
owner: "system",
|
||||
risk: "safe",
|
||||
capability: anyCapability("admin.settings.view"),
|
||||
input: z.object({ count: z.number().int() }),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 2, windowMs: 2_000 },
|
||||
execute: async (context, input) =>
|
||||
ok({ doubled: input.count * 2 }, context.correlationId),
|
||||
};
|
||||
|
||||
const commands = defineHousekeepingCommands(stringCommand, numberCommand);
|
||||
const stringInput: z.input<(typeof commands)[0]["input"]> = {
|
||||
value: "typed",
|
||||
};
|
||||
const numberInput: z.input<(typeof commands)[1]["input"]> = { count: 4 };
|
||||
expect(commands[0].input.parse(stringInput)).toEqual({ value: "typed" });
|
||||
expect(commands[1].input.parse(numberInput)).toEqual({ count: 4 });
|
||||
expect(() => registerHousekeepingCommands(commands)).toThrow(
|
||||
"command registry is sealed",
|
||||
);
|
||||
});
|
||||
it("registers the complete current list and seals during module initialization", () => {
|
||||
expect(housekeepingCommandRegistryReady).toBe(true);
|
||||
expect(
|
||||
SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(SYSTEM_COMMAND_IDS);
|
||||
expect(
|
||||
USER_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(USER_COMMAND_IDS);
|
||||
expect(
|
||||
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(COMMUNITY_COMMAND_IDS);
|
||||
expect(
|
||||
SUPPORT_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(SUPPORT_COMMAND_IDS);
|
||||
expect(
|
||||
MODERATION_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(MODERATION_COMMAND_IDS);
|
||||
expect(() =>
|
||||
registerHousekeepingCommand({
|
||||
id: "system.bootstrap.too-late",
|
||||
owner: "system",
|
||||
risk: "safe",
|
||||
capability: anyCapability("admin.settings.view"),
|
||||
input: z.object({}),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 1, windowMs: 1_000 },
|
||||
execute: async (context) => ok(null, context.correlationId),
|
||||
}),
|
||||
).toThrow("command registry is sealed");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
import { registerHousekeepingCommand } from "./registry";
|
||||
|
||||
type PreserveHousekeepingCommand<Definition> =
|
||||
Definition extends HousekeepingCommand<infer _Input, infer _Output>
|
||||
? Definition
|
||||
: never;
|
||||
|
||||
type HousekeepingCommandTuple<Commands extends readonly unknown[]> =
|
||||
Commands & {
|
||||
readonly [Index in keyof Commands]: PreserveHousekeepingCommand<
|
||||
Commands[Index]
|
||||
>;
|
||||
};
|
||||
|
||||
export function defineHousekeepingCommands<
|
||||
const Commands extends readonly unknown[],
|
||||
>(...commands: HousekeepingCommandTuple<Commands>): Commands {
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function registerHousekeepingCommands<
|
||||
const Commands extends readonly unknown[],
|
||||
>(commands: HousekeepingCommandTuple<Commands>): void {
|
||||
for (const command of commands) {
|
||||
registerHousekeepingCommand(
|
||||
command as unknown as HousekeepingCommand<unknown, unknown>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { anyCapability, ok } from "../contracts";
|
||||
import { confirmHousekeepingCommand } from "./confirmation";
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
|
||||
function command(
|
||||
requiresReason: boolean,
|
||||
risk: "safe" | "sensitive",
|
||||
): HousekeepingCommand<Record<string, never>, null> {
|
||||
return {
|
||||
id: "system.confirmation.test",
|
||||
owner: "system",
|
||||
risk,
|
||||
capability: anyCapability("admin.settings.edit"),
|
||||
input: z.object({}),
|
||||
requiresReason,
|
||||
rateLimit: { attempts: 2, windowMs: 60_000 },
|
||||
execute: async (context) => ok(null, context.correlationId),
|
||||
};
|
||||
}
|
||||
|
||||
describe("housekeeping command confirmation", () => {
|
||||
it("rejects blank reasons when the registered command requires one", () => {
|
||||
expect(
|
||||
confirmHousekeepingCommand(
|
||||
command(true, "sensitive"),
|
||||
" \t ",
|
||||
"corr-reason-missing",
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
messageKey: "errors.housekeeping.validation",
|
||||
fieldErrors: { reason: ["errors.validation.required"] },
|
||||
},
|
||||
correlationId: "corr-reason-missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives confirmation risk from the registered command and normalizes its reason", () => {
|
||||
expect(
|
||||
confirmHousekeepingCommand(
|
||||
command(true, "sensitive"),
|
||||
" Scheduled maintenance ",
|
||||
"corr-confirm",
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
commandId: "system.confirmation.test",
|
||||
risk: "sensitive",
|
||||
requiresReason: true,
|
||||
reason: "Scheduled maintenance",
|
||||
},
|
||||
correlationId: "corr-confirm",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an empty optional reason without inventing confirmation metadata", () => {
|
||||
const result = confirmHousekeepingCommand(
|
||||
command(false, "safe"),
|
||||
undefined,
|
||||
"corr-safe",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
commandId: "system.confirmation.test",
|
||||
risk: "safe",
|
||||
requiresReason: false,
|
||||
},
|
||||
correlationId: "corr-safe",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { fail, type HousekeepingResult, ok } from "../contracts";
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
|
||||
export interface HousekeepingCommandConfirmation {
|
||||
commandId: string;
|
||||
risk: "safe" | "sensitive";
|
||||
requiresReason: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function confirmHousekeepingCommand<I, O>(
|
||||
command: HousekeepingCommand<I, O>,
|
||||
reason: string | undefined,
|
||||
correlationId: string,
|
||||
): HousekeepingResult<HousekeepingCommandConfirmation> {
|
||||
const normalizedReason = reason?.normalize("NFC").trim() || undefined;
|
||||
if (command.requiresReason && !normalizedReason) {
|
||||
return fail("VALIDATION", "errors.housekeeping.validation", correlationId, {
|
||||
reason: ["errors.validation.required"],
|
||||
});
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
commandId: command.id,
|
||||
risk: command.risk,
|
||||
requiresReason: command.requiresReason,
|
||||
...(normalizedReason === undefined ? {} : { reason: normalizedReason }),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,514 +0,0 @@
|
||||
import { isIP } from "node:net";
|
||||
import { z } from "zod";
|
||||
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingPartialCompletion,
|
||||
type HousekeepingResult,
|
||||
mapUnknownError,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import { writeIntent, writeOutcome } from "./audit-envelope";
|
||||
import { confirmHousekeepingCommand } from "./confirmation";
|
||||
import {
|
||||
getHousekeepingCommand,
|
||||
type HousekeepingCommand,
|
||||
type HousekeepingCommandContext,
|
||||
isHousekeepingCommandId,
|
||||
} from "./registry";
|
||||
|
||||
const normalizedCommandId = z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.refine(isHousekeepingCommandId, "invalid command id"),
|
||||
);
|
||||
const normalizedReason = z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
.pipe(z.string().max(1000));
|
||||
const commandRequestSchema = z.strictObject({
|
||||
commandId: normalizedCommandId,
|
||||
input: z.unknown(),
|
||||
reason: normalizedReason.optional(),
|
||||
});
|
||||
const housekeepingErrorCodeSchema = z.enum([
|
||||
"UNAUTHENTICATED",
|
||||
"FORBIDDEN",
|
||||
"VALIDATION",
|
||||
"NOT_FOUND",
|
||||
"CONFLICT",
|
||||
"RATE_LIMITED",
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"TIMEOUT",
|
||||
"INTERNAL",
|
||||
]);
|
||||
const housekeepingErrorSchema = z.strictObject({
|
||||
code: housekeepingErrorCodeSchema,
|
||||
messageKey: z.string().min(1).max(160),
|
||||
fieldErrors: z.record(z.string(), z.array(z.string())).optional(),
|
||||
});
|
||||
const housekeepingResultSchema = z.discriminatedUnion("ok", [
|
||||
z.strictObject({
|
||||
ok: z.literal(true),
|
||||
data: z.unknown(),
|
||||
correlationId: z.string().min(1).max(160),
|
||||
completion: z
|
||||
.strictObject({
|
||||
status: z.literal("partial"),
|
||||
external: z.enum(["not-required", "completed", "failed"]),
|
||||
audit: z.enum(["persisted", "unavailable"]),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
ok: z.literal(false),
|
||||
error: housekeepingErrorSchema,
|
||||
correlationId: z.string().min(1).max(160),
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface HousekeepingCommandRequest {
|
||||
commandId: string;
|
||||
input: unknown;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingCommandDependencies {
|
||||
context: HousekeepingCapabilityContext;
|
||||
ipAddress: string;
|
||||
audit: HousekeepingAuditWriter;
|
||||
rateLimit(key: string, attempts: number, windowMs: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function dispatchHousekeepingCommand(
|
||||
request: unknown,
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const correlationId = createCorrelationId();
|
||||
const ipAddress = canonicalizeServerIp(dependencies.ipAddress);
|
||||
if (ipAddress === undefined) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createDispatchAuditEntry(
|
||||
"server-context",
|
||||
dependencies.context,
|
||||
correlationId,
|
||||
),
|
||||
"failure",
|
||||
mapUnknownError(new Error("invalid server IP"), correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlainRecord(request)) {
|
||||
return persistMalformedRequestOutcome(
|
||||
dependencies,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
let parsedRequest: ReturnType<typeof commandRequestSchema.safeParse>;
|
||||
try {
|
||||
parsedRequest = commandRequestSchema.safeParse(request);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createDispatchAuditEntry(
|
||||
"request-envelope",
|
||||
dependencies.context,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
),
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!parsedRequest.success) {
|
||||
return persistMalformedRequestOutcome(
|
||||
dependencies,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const commandRequest = parsedRequest.data;
|
||||
const command = getHousekeepingCommand(commandRequest.commandId);
|
||||
if (!command) {
|
||||
const result = fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
correlationId,
|
||||
);
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createUnknownCommandAuditEntry(
|
||||
commandRequest.commandId,
|
||||
dependencies.context,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
),
|
||||
"denied",
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
const auditEntry = createAuditEntry(
|
||||
command,
|
||||
dependencies.context,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
commandRequest.reason || undefined,
|
||||
);
|
||||
|
||||
let authorized: boolean;
|
||||
try {
|
||||
authorized = satisfiesCapability(dependencies.context, command.capability);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!authorized) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
let parsedInput: ReturnType<typeof command.input.safeParse>;
|
||||
try {
|
||||
parsedInput = command.input.safeParse(commandRequest.input);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!parsedInput.success) {
|
||||
const fieldErrors: Record<string, readonly string[]> = {};
|
||||
for (const issue of parsedInput.error.issues) {
|
||||
const field = String(issue.path[0] ?? "input");
|
||||
fieldErrors[field] = ["errors.validation.invalid"];
|
||||
}
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
correlationId,
|
||||
fieldErrors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let confirmation: HousekeepingResult<unknown>;
|
||||
try {
|
||||
confirmation = confirmHousekeepingCommand(
|
||||
command,
|
||||
commandRequest.reason || undefined,
|
||||
correlationId,
|
||||
);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!confirmation.ok) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
confirmation,
|
||||
);
|
||||
}
|
||||
|
||||
let allowed: boolean;
|
||||
try {
|
||||
allowed = await dependencies.rateLimit(
|
||||
createRateLimitKey(command.id, dependencies.context, ipAddress),
|
||||
command.rateLimit.attempts,
|
||||
command.rateLimit.windowMs,
|
||||
);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!allowed) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
fail("RATE_LIMITED", "errors.housekeeping.rateLimited", correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
if (command.risk === "sensitive") {
|
||||
try {
|
||||
await writeIntent(dependencies.audit, auditEntry);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error, correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
const commandContext: HousekeepingCommandContext = {
|
||||
capability: dependencies.context,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
};
|
||||
let correlatedResult: HousekeepingResult<unknown>;
|
||||
try {
|
||||
const rawResult = await command.execute(commandContext, parsedInput.data);
|
||||
correlatedResult = validateAndCorrelateResult(rawResult, correlationId);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
if (!correlatedResult.ok) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
outcomeForFailure(correlatedResult),
|
||||
correlatedResult,
|
||||
);
|
||||
}
|
||||
if (correlatedResult.completion?.status === "partial") {
|
||||
return persistPartialCompletionOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
correlatedResult,
|
||||
);
|
||||
}
|
||||
return persistSuccessfulOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
correlatedResult,
|
||||
);
|
||||
}
|
||||
|
||||
function createAuditEntry(
|
||||
command: HousekeepingCommand<unknown, unknown>,
|
||||
context: HousekeepingCapabilityContext,
|
||||
ipAddress: string,
|
||||
correlationId: string,
|
||||
reason: string | undefined,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.actor.id,
|
||||
action: command.id,
|
||||
target: command.owner,
|
||||
correlationId,
|
||||
domain: command.owner,
|
||||
...(reason === undefined ? {} : { reason }),
|
||||
ipAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function createDispatchAuditEntry(
|
||||
target: "request-envelope" | "server-context",
|
||||
context: HousekeepingCapabilityContext,
|
||||
correlationId: string,
|
||||
ipAddress?: string,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.actor.id,
|
||||
action: "housekeeping.command.dispatch",
|
||||
target,
|
||||
correlationId,
|
||||
...(ipAddress === undefined ? {} : { ipAddress }),
|
||||
};
|
||||
}
|
||||
|
||||
function createUnknownCommandAuditEntry(
|
||||
commandId: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
ipAddress: string,
|
||||
correlationId: string,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.actor.id,
|
||||
action: "housekeeping.command.dispatch",
|
||||
target: commandId,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function createRateLimitKey(
|
||||
commandId: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
ipAddress: string,
|
||||
): string {
|
||||
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
|
||||
}
|
||||
|
||||
function canonicalizeServerIp(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const candidate = value.trim();
|
||||
const version = isIP(candidate);
|
||||
if (version === 4) {
|
||||
return candidate
|
||||
.split(".")
|
||||
.map((part) => String(Number(part)))
|
||||
.join(".");
|
||||
}
|
||||
if (version === 6) {
|
||||
try {
|
||||
return new URL(`http://[${candidate}]/`).hostname.slice(1, -1);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAndCorrelateResult(
|
||||
value: unknown,
|
||||
correlationId: string,
|
||||
): HousekeepingResult<unknown> {
|
||||
if (!isPlainRecord(value) || !Object.hasOwn(value, "ok")) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
if (value.ok === true && !Object.hasOwn(value, "data")) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
if (value.ok === false && !Object.hasOwn(value, "error")) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
const parsed = housekeepingResultSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
return { ...parsed.data, correlationId };
|
||||
}
|
||||
|
||||
function outcomeForFailure(
|
||||
result: Extract<HousekeepingResult<unknown>, { ok: false }>,
|
||||
): "failure" | "denied" {
|
||||
return result.error.code === "FORBIDDEN" ||
|
||||
result.error.code === "VALIDATION" ||
|
||||
result.error.code === "RATE_LIMITED"
|
||||
? "denied"
|
||||
: "failure";
|
||||
}
|
||||
|
||||
function persistMalformedRequestOutcome(
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
ipAddress: string,
|
||||
correlationId: string,
|
||||
): Promise<HousekeepingResult<never>> {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createDispatchAuditEntry(
|
||||
"request-envelope",
|
||||
dependencies.context,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
),
|
||||
"denied",
|
||||
fail("VALIDATION", "errors.housekeeping.validation", correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
async function persistReturnedOutcome<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
outcome: "failure" | "denied",
|
||||
result: HousekeepingResult<T>,
|
||||
): Promise<HousekeepingResult<T>> {
|
||||
try {
|
||||
await writeOutcome(writer, entry, outcome);
|
||||
} catch {
|
||||
// The command/preflight failure remains authoritative if evidence is down.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function persistSuccessfulOutcome<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
result: Extract<HousekeepingResult<T>, { ok: true }>,
|
||||
): Promise<HousekeepingResult<T>> {
|
||||
try {
|
||||
await writeOutcome(writer, entry, "success");
|
||||
return result;
|
||||
} catch {
|
||||
let audit: HousekeepingPartialCompletion["audit"] = "persisted";
|
||||
try {
|
||||
await writeOutcome(writer, entry, "partial");
|
||||
} catch {
|
||||
audit = "unavailable";
|
||||
}
|
||||
return withPartialCompletion(result, {
|
||||
status: "partial",
|
||||
external: result.completion?.external ?? "not-required",
|
||||
audit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function persistPartialCompletionOutcome<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
result: Extract<HousekeepingResult<T>, { ok: true }>,
|
||||
): Promise<HousekeepingResult<T>> {
|
||||
try {
|
||||
await writeOutcome(writer, entry, "partial");
|
||||
return result;
|
||||
} catch {
|
||||
return withPartialCompletion(result, {
|
||||
...(result.completion ?? {
|
||||
status: "partial",
|
||||
external: "not-required",
|
||||
audit: "unavailable",
|
||||
}),
|
||||
audit: "unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function withPartialCompletion<T>(
|
||||
result: Extract<HousekeepingResult<T>, { ok: true }>,
|
||||
completion: HousekeepingPartialCompletion,
|
||||
): HousekeepingResult<T> {
|
||||
return { ...result, completion };
|
||||
}
|
||||
@@ -1,886 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { anyCapability, ok } from "../contracts";
|
||||
import {
|
||||
getHousekeepingCommand,
|
||||
type HousekeepingCommand,
|
||||
registerHousekeepingCommand,
|
||||
sealHousekeepingCommandRegistry,
|
||||
UnsupportedHousekeepingCommandSchemaError,
|
||||
} from "./registry";
|
||||
|
||||
function command(
|
||||
id: string,
|
||||
owner: "people" | "system" = "people",
|
||||
): HousekeepingCommand<{ id: number }, { id: number }> {
|
||||
return {
|
||||
id,
|
||||
owner,
|
||||
risk: "safe",
|
||||
capability: anyCapability("admin.users.view"),
|
||||
input: z.object({ id: z.number().int().positive() }),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 4, windowMs: 30_000 },
|
||||
execute: async (context, input) => ok(input, context.correlationId),
|
||||
};
|
||||
}
|
||||
|
||||
type DescriptorReader = (
|
||||
target: object,
|
||||
property: PropertyKey,
|
||||
) => PropertyDescriptor | undefined;
|
||||
|
||||
const descriptorReaders = [
|
||||
[
|
||||
"Object.getOwnPropertyDescriptor",
|
||||
"object-descriptor",
|
||||
Object.getOwnPropertyDescriptor,
|
||||
],
|
||||
[
|
||||
"Object.getOwnPropertyDescriptors",
|
||||
"object-descriptors",
|
||||
(target: object, property: PropertyKey) =>
|
||||
Reflect.get(Object.getOwnPropertyDescriptors(target), property) as
|
||||
| PropertyDescriptor
|
||||
| undefined,
|
||||
],
|
||||
[
|
||||
"Reflect.getOwnPropertyDescriptor",
|
||||
"reflect-descriptor",
|
||||
Reflect.getOwnPropertyDescriptor,
|
||||
],
|
||||
] as const satisfies readonly (readonly [string, string, DescriptorReader])[];
|
||||
|
||||
function readDescriptorValue(
|
||||
target: object,
|
||||
property: PropertyKey,
|
||||
readDescriptor: DescriptorReader,
|
||||
): unknown {
|
||||
const descriptor = readDescriptor(target, property);
|
||||
if (!descriptor) throw new Error(`missing descriptor: ${String(property)}`);
|
||||
if ("value" in descriptor) return descriptor.value;
|
||||
if (descriptor.get) return Reflect.apply(descriptor.get, target, []);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function attemptMutation(mutate: () => void): void {
|
||||
try {
|
||||
mutate();
|
||||
} catch {
|
||||
// Readonly public views may reject the mutation directly.
|
||||
}
|
||||
}
|
||||
|
||||
function mutationWasRejected(mutate: () => void): boolean {
|
||||
try {
|
||||
mutate();
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const unsafeSchemaCallbackMessage =
|
||||
"callback-bearing schema arguments are not supported";
|
||||
|
||||
const callbackOptionMethodNames = [
|
||||
"parse",
|
||||
"safeParse",
|
||||
"parseAsync",
|
||||
"safeParseAsync",
|
||||
"spa",
|
||||
"encode",
|
||||
"decode",
|
||||
"encodeAsync",
|
||||
"decodeAsync",
|
||||
"safeEncode",
|
||||
"safeDecode",
|
||||
"safeEncodeAsync",
|
||||
"safeDecodeAsync",
|
||||
] as const;
|
||||
|
||||
function attemptMinimumMutation(instance: unknown): void {
|
||||
if (typeof instance !== "object" || instance === null) return;
|
||||
const internal = Reflect.get(instance, "_zod") as
|
||||
| { def?: { minimum?: number } }
|
||||
| undefined;
|
||||
if (typeof internal?.def?.minimum !== "number") return;
|
||||
attemptMutation(() => {
|
||||
if (internal.def) internal.def.minimum = 0;
|
||||
});
|
||||
}
|
||||
|
||||
describe("housekeeping command registry", () => {
|
||||
it("returns the validated snapshot registered under its global ID", () => {
|
||||
const registered = command("people.registry.lookup");
|
||||
|
||||
registerHousekeepingCommand(registered);
|
||||
|
||||
expect(getHousekeepingCommand(registered.id)).toMatchObject({
|
||||
id: "people.registry.lookup",
|
||||
owner: "people",
|
||||
risk: "safe",
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 4, windowMs: 30_000 },
|
||||
});
|
||||
expect(getHousekeepingCommand(registered.id)).not.toBe(registered);
|
||||
});
|
||||
|
||||
it("rejects a duplicate global command ID before it can shadow its owner", () => {
|
||||
registerHousekeepingCommand(command("people.registry.duplicate"));
|
||||
|
||||
expect(() =>
|
||||
registerHousekeepingCommand(command("people.registry.duplicate")),
|
||||
).toThrow("duplicate command id: people.registry.duplicate");
|
||||
});
|
||||
|
||||
it("rejects a command whose namespace disagrees with its declared owner", () => {
|
||||
expect(() =>
|
||||
registerHousekeepingCommand(
|
||||
command("people.registry.owner-mismatch", "system"),
|
||||
),
|
||||
).toThrow("command owner mismatch: people.registry.owner-mismatch");
|
||||
});
|
||||
it("stores a frozen snapshot that resists mutation through the original definition", () => {
|
||||
const original = command("people.registry.immutable");
|
||||
const callerOwnedInput = original.input;
|
||||
registerHousekeepingCommand(original);
|
||||
const registered = getHousekeepingCommand(original.id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
const registeredInput = registered.input;
|
||||
const registeredExecute = registered.execute;
|
||||
(original as { risk: "safe" | "sensitive" }).risk = "sensitive";
|
||||
(original as { owner: "people" | "system" }).owner = "system";
|
||||
(original as { input: z.ZodType<{ id: number }> }).input = z.object({
|
||||
id: z.literal(999),
|
||||
});
|
||||
(original.capability.slugs as string[]).push("admin.settings.edit");
|
||||
(original.rateLimit as { attempts: number }).attempts = 999;
|
||||
(
|
||||
original as {
|
||||
execute: HousekeepingCommand<{ id: number }, { id: number }>["execute"];
|
||||
}
|
||||
).execute = async (context) => ok({ id: 999 }, context.correlationId);
|
||||
|
||||
expect(registered).toMatchObject({
|
||||
risk: "safe",
|
||||
owner: "people",
|
||||
capability: { slugs: ["admin.users.view"] },
|
||||
rateLimit: { attempts: 4, windowMs: 30_000 },
|
||||
});
|
||||
expect(registered.input).toBe(registeredInput);
|
||||
expect(registered.input).not.toBe(callerOwnedInput);
|
||||
expect(Object.isFrozen(callerOwnedInput)).toBe(false);
|
||||
expect(registered.execute).toBe(registeredExecute);
|
||||
expect(Object.isFrozen(registered)).toBe(true);
|
||||
expect(Object.isFrozen(registered.input)).toBe(true);
|
||||
expect(Object.isFrozen(registered.capability)).toBe(true);
|
||||
expect(Object.isFrozen(registered.capability.slugs)).toBe(true);
|
||||
expect(Object.isFrozen(registered.rateLimit)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["risk", { risk: "dangerous" }],
|
||||
["requiresReason", { requiresReason: "yes" }],
|
||||
[
|
||||
"capability mode",
|
||||
{ capability: { mode: "none", slugs: ["admin.users.view"] } },
|
||||
],
|
||||
["empty capability", { capability: { mode: "any", slugs: [] } }],
|
||||
[
|
||||
"unknown capability",
|
||||
{ capability: { mode: "any", slugs: ["forged.permission"] } },
|
||||
],
|
||||
["execute", { execute: null }],
|
||||
[
|
||||
"Zod schema",
|
||||
{ input: { safeParse: () => ({ success: true, data: {} }) } },
|
||||
],
|
||||
["normalized ID", { id: " people.registry.invalid-id " }],
|
||||
] as const)("rejects an invalid %s definition", (label, override) => {
|
||||
const invalid = {
|
||||
...command(
|
||||
`people.registry.invalid-${label.toLowerCase().replaceAll(" ", "-")}`,
|
||||
),
|
||||
...override,
|
||||
} as unknown as HousekeepingCommand<{ id: number }, { id: number }>;
|
||||
|
||||
expect(() => registerHousekeepingCommand(invalid)).toThrow();
|
||||
});
|
||||
|
||||
it("keeps registered validation unchanged after original shape and child mutation", () => {
|
||||
const child = z.string().min(3);
|
||||
const input = z.object({ value: child, guard: child });
|
||||
registerHousekeepingCommand({
|
||||
...command("people.registry.deep-validation"),
|
||||
input,
|
||||
execute: async (context, value) =>
|
||||
ok({ id: value.value.length }, context.correlationId),
|
||||
} as HousekeepingCommand<{ value: string; guard: string }, { id: number }>);
|
||||
const registered = getHousekeepingCommand(
|
||||
"people.registry.deep-validation",
|
||||
);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
(input.def as { shape: { value: z.ZodType } }).shape.value = z.number();
|
||||
const minCheck = (child.def as { checks: unknown[] }).checks[0] as {
|
||||
_zod: { def: { minimum: number } };
|
||||
};
|
||||
minCheck._zod.def.minimum = 0;
|
||||
|
||||
expect(input.safeParse({ value: 4, guard: "a" }).success).toBe(true);
|
||||
expect(
|
||||
registered.input.safeParse({ value: "abcd", guard: "ab" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
registered.input.safeParse({ value: "abcd", guard: "abcd" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
registered.input.safeParse({ value: 4, guard: "abcd" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("snapshots caller-owned lazy targets across supported container schemas", () => {
|
||||
let lazyChild: z.ZodType = z.string().min(2);
|
||||
let defaultValue = "registered-default";
|
||||
const input = z.object({
|
||||
choice: z.union([z.lazy(() => lazyChild), z.number().int()]),
|
||||
optional: z.lazy(() => lazyChild).optional(),
|
||||
defaulted: z.string().default(() => defaultValue),
|
||||
list: z.array(z.lazy(() => lazyChild)),
|
||||
lookup: z.record(
|
||||
z.string(),
|
||||
z.lazy(() => lazyChild),
|
||||
),
|
||||
});
|
||||
registerHousekeepingCommand({
|
||||
...command("people.registry.lazy-containers"),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(
|
||||
"people.registry.lazy-containers",
|
||||
);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
lazyChild = z.boolean();
|
||||
defaultValue = "caller-mutated-default";
|
||||
|
||||
const parsed = registered.input.safeParse({
|
||||
choice: "ok",
|
||||
list: ["one"],
|
||||
lookup: { key: "two" },
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
success: true,
|
||||
data: { defaulted: "registered-default" },
|
||||
});
|
||||
expect(
|
||||
registered.input.safeParse({
|
||||
choice: true,
|
||||
optional: true,
|
||||
list: [true],
|
||||
lookup: { key: true },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("snapshots recursive lazy back-edges with cycle safety", () => {
|
||||
type TreeNode = { name: string; children: TreeNode[] };
|
||||
let nameSchema: z.ZodType = z.string().min(2);
|
||||
let recursiveSchema: z.ZodType<TreeNode>;
|
||||
recursiveSchema = z.object({
|
||||
name: z.lazy(() => nameSchema),
|
||||
children: z.array(z.lazy(() => recursiveSchema)),
|
||||
}) as z.ZodType<TreeNode>;
|
||||
registerHousekeepingCommand({
|
||||
...command("people.registry.recursive-lazy"),
|
||||
input: recursiveSchema,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<TreeNode, { id: number }>);
|
||||
const registered = getHousekeepingCommand("people.registry.recursive-lazy");
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
nameSchema = z.number();
|
||||
recursiveSchema = z.object({
|
||||
name: z.number(),
|
||||
children: z.array(z.unknown()),
|
||||
}) as unknown as z.ZodType<TreeNode>;
|
||||
|
||||
expect(
|
||||
registered.input.safeParse({
|
||||
name: "root",
|
||||
children: [{ name: "leaf", children: [] }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
registered.input.safeParse({
|
||||
name: "root",
|
||||
children: [{ name: 7, children: [] }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["refine", "custom", z.string().refine((value) => value === "allowed")],
|
||||
["super-refine", "custom", z.string().superRefine(() => undefined)],
|
||||
[
|
||||
"preprocess",
|
||||
"transform",
|
||||
z.preprocess((value) => String(value), z.string()),
|
||||
],
|
||||
["transform", "transform", z.string().transform((value) => value.length)],
|
||||
["async-refine", "custom", z.string().refine(async () => true)],
|
||||
] as const)(
|
||||
"rejects unsupported executable validation schema %s",
|
||||
(suffix, schemaKind, input) => {
|
||||
const id = `people.registry.unsupported-${suffix}`;
|
||||
let thrown: unknown;
|
||||
try {
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<unknown, { id: number }>);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(UnsupportedHousekeepingCommandSchemaError);
|
||||
expect(thrown).toMatchObject({
|
||||
name: "UnsupportedHousekeepingCommandSchemaError",
|
||||
code: "UNSUPPORTED_COMMAND_INPUT_SCHEMA",
|
||||
commandId: id,
|
||||
schemaKind,
|
||||
});
|
||||
expect(getHousekeepingCommand(id)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a lazy edge that cannot be resolved at registration", () => {
|
||||
const id = "people.registry.unresolvable-lazy";
|
||||
let thrown: unknown;
|
||||
try {
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input: z.lazy(() => {
|
||||
throw new Error("caller lazy secret");
|
||||
}),
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<unknown, { id: number }>);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(UnsupportedHousekeepingCommandSchemaError);
|
||||
expect(thrown).toMatchObject({
|
||||
name: "UnsupportedHousekeepingCommandSchemaError",
|
||||
code: "UNSUPPORTED_COMMAND_INPUT_SCHEMA",
|
||||
commandId: id,
|
||||
schemaKind: "lazy",
|
||||
});
|
||||
expect(String(thrown)).not.toContain("caller lazy secret");
|
||||
});
|
||||
it.each(descriptorReaders)(
|
||||
"keeps private validation unchanged after nested mutation through %s",
|
||||
(_api, suffix, readDescriptor) => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = `people.registry.${suffix}`;
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
const definition = readDescriptorValue(
|
||||
registered.input,
|
||||
"def",
|
||||
readDescriptor,
|
||||
) as object;
|
||||
const shape = readDescriptorValue(
|
||||
definition,
|
||||
"shape",
|
||||
readDescriptor,
|
||||
) as { value: z.ZodType };
|
||||
const child = shape.value;
|
||||
const childDefinition = readDescriptorValue(
|
||||
child,
|
||||
"def",
|
||||
readDescriptor,
|
||||
) as object;
|
||||
const checks = readDescriptorValue(
|
||||
childDefinition,
|
||||
"checks",
|
||||
readDescriptor,
|
||||
) as readonly object[];
|
||||
const internal = readDescriptorValue(
|
||||
checks[0] as object,
|
||||
"_zod",
|
||||
readDescriptor,
|
||||
) as object;
|
||||
const checkDefinition = readDescriptorValue(
|
||||
internal,
|
||||
"def",
|
||||
readDescriptor,
|
||||
) as { minimum: number };
|
||||
|
||||
attemptMutation(() => {
|
||||
shape.value = z.number();
|
||||
});
|
||||
attemptMutation(() => {
|
||||
checkDefinition.minimum = 0;
|
||||
});
|
||||
|
||||
expect(registered.input.safeParse({ value: "ab" }).success).toBe(false);
|
||||
expect(registered.input.safeParse({ value: "abcd" }).success).toBe(true);
|
||||
expect(registered.input.safeParse({ value: 7 }).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps own-key and symbol iteration detached from private checks", () => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = "people.registry.symbol-iteration";
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
const publicInput = registered.input as z.ZodObject<{
|
||||
value: z.ZodString;
|
||||
}>;
|
||||
const shape = publicInput.shape;
|
||||
expect(Reflect.ownKeys(publicInput.def)).toContain("shape");
|
||||
expect(Object.keys(shape)).toEqual(["value"]);
|
||||
const child = Object.entries(shape)[0]?.[1];
|
||||
if (!child) throw new Error("public child schema missing");
|
||||
const checks = child.def.checks ?? [];
|
||||
const spreadChecks = [...checks];
|
||||
const iterator = checks[Symbol.iterator]();
|
||||
const iteratedCheck = iterator.next().value;
|
||||
if (!iteratedCheck) throw new Error("public check missing");
|
||||
expect(spreadChecks[0]).toBe(iteratedCheck);
|
||||
|
||||
attemptMutation(() => {
|
||||
(
|
||||
iteratedCheck as unknown as {
|
||||
_zod: { def: { minimum: number } };
|
||||
}
|
||||
)._zod.def.minimum = 0;
|
||||
});
|
||||
|
||||
expect(registered.input.safeParse({ value: "ab" }).success).toBe(false);
|
||||
expect(registered.input.safeParse({ value: "abcd" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps prototype methods operational without passing the private schema to callbacks", async () => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = "people.registry.prototype-methods";
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
const publicInput = registered.input as z.ZodObject<{
|
||||
value: z.ZodString;
|
||||
}>;
|
||||
|
||||
const publicPrototype = Object.getPrototypeOf(publicInput);
|
||||
expect(publicPrototype).toBe(Reflect.getPrototypeOf(publicInput));
|
||||
const prototypeMutation = Symbol("prototype-mutation");
|
||||
Object.defineProperty(publicPrototype, prototypeMutation, {
|
||||
configurable: true,
|
||||
value(this: z.ZodObject<{ value: z.ZodString }>): unknown {
|
||||
attemptMutation(() => {
|
||||
this.def.shape.value = z.number() as never;
|
||||
});
|
||||
return this.def;
|
||||
},
|
||||
});
|
||||
try {
|
||||
(
|
||||
publicInput as typeof publicInput & {
|
||||
[prototypeMutation](): unknown;
|
||||
}
|
||||
)[prototypeMutation]();
|
||||
} finally {
|
||||
Reflect.deleteProperty(publicPrototype, prototypeMutation);
|
||||
}
|
||||
expect(publicInput.safeParse({ value: "abcd" }).success).toBe(true);
|
||||
expect(publicInput.safeParse({ value: 7 }).success).toBe(false);
|
||||
|
||||
let appliedSchema: z.ZodType | undefined;
|
||||
const applied = publicInput.apply((schema) => {
|
||||
appliedSchema = schema;
|
||||
return schema;
|
||||
});
|
||||
expect(appliedSchema).toBe(publicInput);
|
||||
expect(applied).toBe(publicInput);
|
||||
|
||||
let externallyRegistered: z.ZodType | undefined;
|
||||
const externalRegistry = {
|
||||
add(schema: z.ZodType): void {
|
||||
externallyRegistered = schema;
|
||||
},
|
||||
};
|
||||
expect(
|
||||
publicInput.register(externalRegistry as never, undefined as never),
|
||||
).toBe(publicInput);
|
||||
expect(externallyRegistered).toBe(publicInput);
|
||||
|
||||
const partial = publicInput.partial();
|
||||
const picked = publicInput.pick({ value: true });
|
||||
expect(partial.safeParse({}).success).toBe(true);
|
||||
expect(picked.safeParse({ value: "abcd" }).success).toBe(true);
|
||||
expect((await publicInput.safeParseAsync({ value: "ab" })).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect((await publicInput.safeParseAsync({ value: "abcd" })).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it.each(callbackOptionMethodNames)(
|
||||
"rejects callback-bearing %s options before private issue nodes are exposed",
|
||||
async (methodName) => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = `people.registry.callback-${methodName.toLowerCase()}`;
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
let callbackCalls = 0;
|
||||
let capturedInstance: unknown;
|
||||
const errorCallback = (issue: unknown): string => {
|
||||
callbackCalls += 1;
|
||||
capturedInstance = Reflect.get(issue as object, "inst");
|
||||
attemptMinimumMutation(capturedInstance);
|
||||
return "forged validation error";
|
||||
};
|
||||
const method = Reflect.get(registered.input, methodName);
|
||||
if (typeof method !== "function") {
|
||||
throw new Error(`public schema method missing: ${methodName}`);
|
||||
}
|
||||
const attack = Promise.resolve().then(() =>
|
||||
Reflect.apply(method, registered.input, [
|
||||
{ value: "x" },
|
||||
{ error: errorCallback },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(attack).rejects.toThrow(unsafeSchemaCallbackMessage);
|
||||
expect(callbackCalls).toBe(0);
|
||||
expect(capturedInstance).toBeUndefined();
|
||||
expect(registered.input.safeParse({ value: "x" }).success).toBe(false);
|
||||
expect(registered.input.safeParse({ value: "valid" }).success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects JSON-schema overrides before private schema or check nodes are exposed", () => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = "people.registry.callback-json-schema";
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
let callbackCalls = 0;
|
||||
let capturedSchema: unknown;
|
||||
let attackError: unknown;
|
||||
try {
|
||||
registered.input.toJSONSchema({
|
||||
override: ({ zodSchema }) => {
|
||||
callbackCalls += 1;
|
||||
capturedSchema = zodSchema;
|
||||
const checks = (zodSchema._zod.def as { checks?: unknown[] }).checks;
|
||||
if (checks?.[0]) attemptMinimumMutation(checks[0]);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
attackError = error;
|
||||
}
|
||||
|
||||
expect({
|
||||
attackError:
|
||||
attackError instanceof TypeError ? attackError.message : undefined,
|
||||
callbackCalls,
|
||||
capturedSchema,
|
||||
shortValueAccepted: registered.input.safeParse({ value: "x" }).success,
|
||||
validValueAccepted: registered.input.safeParse({ value: "valid" })
|
||||
.success,
|
||||
}).toEqual({
|
||||
attackError: unsafeSchemaCallbackMessage,
|
||||
callbackCalls: 0,
|
||||
capturedSchema: undefined,
|
||||
shortValueAccepted: false,
|
||||
validValueAccepted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an error callback concealed behind an option Proxy", () => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = "people.registry.callback-proxy-options";
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
let callbackCalls = 0;
|
||||
let capturedInstance: unknown;
|
||||
const errorCallback = (issue: unknown): string => {
|
||||
callbackCalls += 1;
|
||||
capturedInstance = Reflect.get(issue as object, "inst");
|
||||
attemptMinimumMutation(capturedInstance);
|
||||
return "forged validation error";
|
||||
};
|
||||
const concealedOptions = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property) =>
|
||||
property === "error" ? errorCallback : undefined,
|
||||
getOwnPropertyDescriptor: () => undefined,
|
||||
ownKeys: () => [],
|
||||
},
|
||||
);
|
||||
let attackError: unknown;
|
||||
try {
|
||||
registered.input.safeParse({ value: "x" }, concealedOptions as never);
|
||||
} catch (error) {
|
||||
attackError = error;
|
||||
}
|
||||
|
||||
expect({
|
||||
attackError:
|
||||
attackError instanceof TypeError ? attackError.message : undefined,
|
||||
callbackCalls,
|
||||
capturedInstance,
|
||||
shortValueAccepted: registered.input.safeParse({ value: "x" }).success,
|
||||
validValueAccepted: registered.input.safeParse({ value: "valid" })
|
||||
.success,
|
||||
}).toEqual({
|
||||
attackError: unsafeSchemaCallbackMessage,
|
||||
callbackCalls: 0,
|
||||
capturedInstance: undefined,
|
||||
shortValueAccepted: false,
|
||||
validValueAccepted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps callback-free sync and async parsing available", async () => {
|
||||
const input = z.object({ value: z.string().min(3) });
|
||||
const id = "people.registry.callback-free-parse";
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
|
||||
expect(
|
||||
registered.input.parse({ value: "valid" }, { jitless: true }),
|
||||
).toEqual({ value: "valid" });
|
||||
expect(
|
||||
registered.input.safeParse({ value: "valid" }, { reportInput: true }),
|
||||
).toMatchObject({ success: true, data: { value: "valid" } });
|
||||
await expect(
|
||||
registered.input.parseAsync({ value: "valid" }, { jitless: true }),
|
||||
).resolves.toEqual({ value: "valid" });
|
||||
await expect(
|
||||
registered.input.safeParseAsync({ value: "x" }, { reportInput: true }),
|
||||
).resolves.toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it("exposes detached Map, Set, and Date values as behaviorally readonly views", () => {
|
||||
const sourceMap = new Map<string, { count: number }>([
|
||||
["registered", { count: 1 }],
|
||||
]);
|
||||
const sourceSet = new Set(["registered"]);
|
||||
const sourceDate = new Date("2026-08-26T12:34:56.000Z");
|
||||
const input = z.object({ value: z.string() });
|
||||
Object.assign(input.def, {
|
||||
exposedMap: sourceMap,
|
||||
exposedSet: sourceSet,
|
||||
exposedDate: sourceDate,
|
||||
});
|
||||
const id = "people.registry.readonly-builtins";
|
||||
registerHousekeepingCommand({
|
||||
...command(id),
|
||||
input,
|
||||
execute: async (context) => ok({ id: 1 }, context.correlationId),
|
||||
} as HousekeepingCommand<z.output<typeof input>, { id: number }>);
|
||||
const registered = getHousekeepingCommand(id);
|
||||
if (!registered) throw new Error("registered command missing");
|
||||
const publicDefinition = registered.input
|
||||
.def as typeof registered.input.def & {
|
||||
exposedMap: ReadonlyMap<string, { count: number }>;
|
||||
exposedSet: ReadonlySet<string>;
|
||||
exposedDate: Date;
|
||||
};
|
||||
const publicMap = publicDefinition.exposedMap;
|
||||
const publicSet = publicDefinition.exposedSet;
|
||||
const publicDate = publicDefinition.exposedDate;
|
||||
|
||||
sourceMap.set("caller-late", { count: 2 });
|
||||
sourceSet.add("caller-late");
|
||||
sourceDate.setTime(0);
|
||||
const mapForEachReceivers: unknown[] = [];
|
||||
const mapEntries: Array<[string, number]> = [];
|
||||
publicMap.forEach((value, key, collection) => {
|
||||
mapEntries.push([key, value.count]);
|
||||
mapForEachReceivers.push(collection);
|
||||
});
|
||||
const setForEachReceivers: unknown[] = [];
|
||||
const setEntries: string[] = [];
|
||||
publicSet.forEach((value, duplicate, collection) => {
|
||||
expect(duplicate).toBe(value);
|
||||
setEntries.push(value);
|
||||
setForEachReceivers.push(collection);
|
||||
});
|
||||
const dateIsoBefore = publicDate.toISOString();
|
||||
|
||||
const mapEscape = Symbol("map-escape");
|
||||
const setEscape = Symbol("set-escape");
|
||||
const dateEscape = Symbol("date-escape");
|
||||
Object.defineProperty(Map.prototype, mapEscape, {
|
||||
configurable: true,
|
||||
value(this: Map<unknown, unknown>): Map<unknown, unknown> {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(Set.prototype, setEscape, {
|
||||
configurable: true,
|
||||
value(this: Set<unknown>): Set<unknown> {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(Date.prototype, dateEscape, {
|
||||
configurable: true,
|
||||
value(this: Date): Date {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
let escapedMap: unknown;
|
||||
let escapedSet: unknown;
|
||||
let escapedDate: unknown;
|
||||
try {
|
||||
const mapMethod = Reflect.get(publicMap, mapEscape);
|
||||
const setMethod = Reflect.get(publicSet, setEscape);
|
||||
const dateMethod = Reflect.get(publicDate, dateEscape);
|
||||
if (
|
||||
typeof mapMethod !== "function" ||
|
||||
typeof setMethod !== "function" ||
|
||||
typeof dateMethod !== "function"
|
||||
) {
|
||||
throw new Error("dynamic builtin method missing");
|
||||
}
|
||||
escapedMap = Reflect.apply(mapMethod, publicMap, []);
|
||||
escapedSet = Reflect.apply(setMethod, publicSet, []);
|
||||
escapedDate = Reflect.apply(dateMethod, publicDate, []);
|
||||
} finally {
|
||||
Reflect.deleteProperty(Map.prototype, mapEscape);
|
||||
Reflect.deleteProperty(Set.prototype, setEscape);
|
||||
Reflect.deleteProperty(Date.prototype, dateEscape);
|
||||
}
|
||||
const escapeMutationResults = [
|
||||
mutationWasRejected(() =>
|
||||
(escapedMap as Map<string, { count: number }>).set("escaped-forged", {
|
||||
count: 11,
|
||||
}),
|
||||
),
|
||||
mutationWasRejected(() =>
|
||||
(escapedSet as Set<string>).add("escaped-forged"),
|
||||
),
|
||||
mutationWasRejected(() => (escapedDate as Date).setTime(0)),
|
||||
];
|
||||
|
||||
const mapMutationResults = [
|
||||
mutationWasRejected(() =>
|
||||
(publicMap as Map<string, { count: number }>).set("forged", {
|
||||
count: 9,
|
||||
}),
|
||||
),
|
||||
mutationWasRejected(() =>
|
||||
(publicMap as Map<string, { count: number }>).delete("registered"),
|
||||
),
|
||||
mutationWasRejected(() =>
|
||||
(publicMap as Map<string, { count: number }>).clear(),
|
||||
),
|
||||
mutationWasRejected(() =>
|
||||
Map.prototype.set.call(
|
||||
publicMap as Map<string, { count: number }>,
|
||||
"prototype-forged",
|
||||
{ count: 10 },
|
||||
),
|
||||
),
|
||||
];
|
||||
const setMutationResults = [
|
||||
mutationWasRejected(() => (publicSet as Set<string>).add("forged")),
|
||||
mutationWasRejected(() =>
|
||||
(publicSet as Set<string>).delete("registered"),
|
||||
),
|
||||
mutationWasRejected(() => (publicSet as Set<string>).clear()),
|
||||
mutationWasRejected(() =>
|
||||
Set.prototype.add.call(publicSet as Set<string>, "prototype-forged"),
|
||||
),
|
||||
];
|
||||
const dateMutationResults = Object.getOwnPropertyNames(Date.prototype)
|
||||
.filter((property) => property.startsWith("set"))
|
||||
.map((property) =>
|
||||
mutationWasRejected(() => {
|
||||
const method = Reflect.get(publicDate, property);
|
||||
if (typeof method !== "function") {
|
||||
throw new TypeError(`date mutator unavailable: ${property}`);
|
||||
}
|
||||
Reflect.apply(method, publicDate, [0]);
|
||||
}),
|
||||
);
|
||||
dateMutationResults.push(
|
||||
mutationWasRejected(() => Date.prototype.setTime.call(publicDate, 0)),
|
||||
);
|
||||
let dateIsoAfter: string | undefined;
|
||||
try {
|
||||
dateIsoAfter = publicDate.toISOString();
|
||||
} catch {
|
||||
dateIsoAfter = undefined;
|
||||
}
|
||||
|
||||
expect(dateMutationResults.length).toBeGreaterThan(1);
|
||||
expect(dateMutationResults.every(Boolean)).toBe(true);
|
||||
expect(escapeMutationResults).toEqual([true, true, true]);
|
||||
expect(mapEntries).toEqual([["registered", 1]]);
|
||||
expect(mapForEachReceivers).toHaveLength(1);
|
||||
expect(mapForEachReceivers[0]).toBe(publicMap);
|
||||
expect(mapMutationResults).toEqual([true, true, true, true]);
|
||||
expect([...publicMap.entries()]).toEqual([["registered", { count: 1 }]]);
|
||||
expect(setEntries).toEqual(["registered"]);
|
||||
expect(setForEachReceivers).toHaveLength(1);
|
||||
expect(setForEachReceivers[0]).toBe(publicSet);
|
||||
expect(setMutationResults).toEqual([true, true, true, true]);
|
||||
expect([...publicSet]).toEqual(["registered"]);
|
||||
expect(dateIsoBefore).toBe("2026-08-26T12:34:56.000Z");
|
||||
expect(dateIsoAfter).toBe("2026-08-26T12:34:56.000Z");
|
||||
});
|
||||
it("seals the global registry after deterministic bootstrap", () => {
|
||||
sealHousekeepingCommandRegistry();
|
||||
|
||||
expect(() =>
|
||||
registerHousekeepingCommand(command("people.registry.after-seal")),
|
||||
).toThrow("command registry is sealed");
|
||||
});
|
||||
});
|
||||
@@ -1,936 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
HOUSEKEEPING_DOMAIN_IDS,
|
||||
type HousekeepingDomainId,
|
||||
} from "../../migration/types";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "../contracts";
|
||||
|
||||
export interface HousekeepingCommandContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly ipAddress: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingCommand<I, O> {
|
||||
readonly id: string;
|
||||
readonly owner: HousekeepingDomainId;
|
||||
readonly risk: "safe" | "sensitive";
|
||||
readonly capability: CapabilityRequirement;
|
||||
/**
|
||||
* Registration snapshots structural/built-in Zod graphs and resolvable lazy
|
||||
* edges. Stateful custom refinements, transforms, preprocessors, and other
|
||||
* executable schema callbacks are rejected. The registered public facade
|
||||
* accepts callback-free parse, codec, and JSON-schema options only.
|
||||
*/
|
||||
readonly input: z.ZodType<I>;
|
||||
readonly requiresReason: boolean;
|
||||
readonly rateLimit: Readonly<{ attempts: number; windowMs: number }>;
|
||||
readonly execute: (
|
||||
context: HousekeepingCommandContext,
|
||||
input: I,
|
||||
) => Promise<HousekeepingResult<O>>;
|
||||
}
|
||||
|
||||
export type UnsupportedHousekeepingCommandSchemaKind =
|
||||
| "custom"
|
||||
| "transform"
|
||||
| "lazy"
|
||||
| "executable";
|
||||
|
||||
export class UnsupportedHousekeepingCommandSchemaError extends Error {
|
||||
readonly name = "UnsupportedHousekeepingCommandSchemaError";
|
||||
readonly code = "UNSUPPORTED_COMMAND_INPUT_SCHEMA";
|
||||
|
||||
constructor(
|
||||
readonly commandId: string,
|
||||
readonly schemaKind: UnsupportedHousekeepingCommandSchemaKind,
|
||||
) {
|
||||
super(`unsupported command input schema: ${commandId} (${schemaKind})`);
|
||||
}
|
||||
}
|
||||
|
||||
type RegisteredHousekeepingCommand = HousekeepingCommand<unknown, unknown>;
|
||||
|
||||
type ZodInternalNode = {
|
||||
readonly _zod: {
|
||||
readonly constr: new (definition: never) => unknown;
|
||||
readonly def: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const approvedOwners = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
||||
const commandIdPattern = /^[a-z][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/;
|
||||
const commands = new Map<string, RegisteredHousekeepingCommand>();
|
||||
let sealed = false;
|
||||
|
||||
export function registerHousekeepingCommand<I, O>(
|
||||
command: HousekeepingCommand<I, O>,
|
||||
): void {
|
||||
if (sealed) throw new Error("command registry is sealed");
|
||||
validateCommand(command);
|
||||
if (commands.has(command.id)) {
|
||||
throw new Error(`duplicate command id: ${command.id}`);
|
||||
}
|
||||
|
||||
const capability = Object.freeze({
|
||||
mode: command.capability.mode,
|
||||
slugs: Object.freeze([...command.capability.slugs]),
|
||||
}) satisfies CapabilityRequirement;
|
||||
const snapshot = Object.freeze({
|
||||
id: command.id,
|
||||
owner: command.owner,
|
||||
risk: command.risk,
|
||||
capability,
|
||||
input: isolateValidationGraph(command.input, command.id),
|
||||
requiresReason: command.requiresReason,
|
||||
rateLimit: Object.freeze({ ...command.rateLimit }),
|
||||
execute: command.execute,
|
||||
}) as RegisteredHousekeepingCommand;
|
||||
commands.set(snapshot.id, snapshot);
|
||||
}
|
||||
|
||||
export function sealHousekeepingCommandRegistry(): void {
|
||||
sealed = true;
|
||||
}
|
||||
|
||||
export function getHousekeepingCommand(
|
||||
id: string,
|
||||
): RegisteredHousekeepingCommand | undefined {
|
||||
return commands.get(id);
|
||||
}
|
||||
|
||||
export function listHousekeepingCommands(): readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[] {
|
||||
return Object.freeze([...commands.values()]);
|
||||
}
|
||||
|
||||
export function isHousekeepingCommandId(value: unknown): value is string {
|
||||
return isNormalizedIdentifier(value) && commandIdPattern.test(value);
|
||||
}
|
||||
|
||||
function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||
if (!isHousekeepingCommandId(command.id)) {
|
||||
throw new Error(`invalid command id: ${String(command.id)}`);
|
||||
}
|
||||
if (
|
||||
!isNormalizedIdentifier(command.owner) ||
|
||||
!approvedOwners.has(command.owner)
|
||||
) {
|
||||
throw new Error(`unknown command owner: ${String(command.owner)}`);
|
||||
}
|
||||
if (!command.id.startsWith(`${command.owner}.`)) {
|
||||
throw new Error(`command owner mismatch: ${command.id}`);
|
||||
}
|
||||
if (command.risk !== "safe" && command.risk !== "sensitive") {
|
||||
throw new Error(`invalid command risk: ${command.id}`);
|
||||
}
|
||||
if (typeof command.requiresReason !== "boolean") {
|
||||
throw new Error(`invalid command reason policy: ${command.id}`);
|
||||
}
|
||||
validateCapability(command.capability, command.id);
|
||||
if (!(command.input instanceof z.ZodType)) {
|
||||
throw new Error(`command input schema missing: ${command.id}`);
|
||||
}
|
||||
if (typeof command.execute !== "function") {
|
||||
throw new Error(`command execute missing: ${command.id}`);
|
||||
}
|
||||
if (
|
||||
typeof command.rateLimit !== "object" ||
|
||||
command.rateLimit === null ||
|
||||
!Number.isInteger(command.rateLimit.attempts) ||
|
||||
command.rateLimit.attempts <= 0 ||
|
||||
!Number.isInteger(command.rateLimit.windowMs) ||
|
||||
command.rateLimit.windowMs <= 0
|
||||
) {
|
||||
throw new Error(`invalid command rate limit: ${command.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCapability(
|
||||
requirement: CapabilityRequirement,
|
||||
commandId: string,
|
||||
): void {
|
||||
if (
|
||||
typeof requirement !== "object" ||
|
||||
requirement === null ||
|
||||
(requirement.mode !== "any" && requirement.mode !== "all") ||
|
||||
!Array.isArray(requirement.slugs) ||
|
||||
requirement.slugs.length === 0
|
||||
) {
|
||||
throw new Error(`invalid command capability: ${commandId}`);
|
||||
}
|
||||
for (const slug of requirement.slugs) {
|
||||
if (!isNormalizedIdentifier(slug) || !knownCapabilitySlugs.has(slug)) {
|
||||
throw new Error(`invalid command capability: ${commandId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isolateValidationGraph<T extends z.ZodType>(
|
||||
schema: T,
|
||||
commandId: string,
|
||||
): T {
|
||||
type SchemaCell = {
|
||||
current?: z.ZodType;
|
||||
reference?: z.ZodType;
|
||||
};
|
||||
|
||||
const graphValues = new WeakMap<object, unknown>();
|
||||
const schemaCells = new WeakMap<z.ZodType, SchemaCell>();
|
||||
|
||||
function unsupported(
|
||||
schemaKind: UnsupportedHousekeepingCommandSchemaKind,
|
||||
): never {
|
||||
throw new UnsupportedHousekeepingCommandSchemaError(commandId, schemaKind);
|
||||
}
|
||||
|
||||
function cloneSchema<S extends z.ZodType>(value: S): S {
|
||||
const existing = schemaCells.get(value);
|
||||
if (existing?.current) return existing.current as S;
|
||||
if (existing) {
|
||||
existing.reference ??= z.lazy(() => {
|
||||
if (!existing.current) {
|
||||
throw new Error("housekeeping schema registration incomplete");
|
||||
}
|
||||
return existing.current;
|
||||
});
|
||||
return existing.reference as S;
|
||||
}
|
||||
|
||||
const definition = value.def as { type?: unknown; getter?: unknown };
|
||||
if (definition.type === "custom") unsupported("custom");
|
||||
if (definition.type === "transform") unsupported("transform");
|
||||
|
||||
const cell: SchemaCell = {};
|
||||
schemaCells.set(value, cell);
|
||||
if (definition.type === "lazy") {
|
||||
let ownedTarget: z.ZodType | undefined;
|
||||
const ownedLazy = z.lazy(() => {
|
||||
if (!ownedTarget) {
|
||||
throw new Error("housekeeping lazy schema target unavailable");
|
||||
}
|
||||
return ownedTarget;
|
||||
});
|
||||
cell.current = ownedLazy;
|
||||
if (typeof definition.getter !== "function") unsupported("lazy");
|
||||
|
||||
let callerTarget: unknown;
|
||||
try {
|
||||
callerTarget = definition.getter();
|
||||
} catch {
|
||||
unsupported("lazy");
|
||||
}
|
||||
if (!(callerTarget instanceof z.ZodType)) unsupported("lazy");
|
||||
ownedTarget = cloneSchema(callerTarget);
|
||||
return ownedLazy as unknown as S;
|
||||
}
|
||||
|
||||
const clonedDefinition = cloneGraph(value.def);
|
||||
const cloned = value.clone(clonedDefinition as typeof value.def);
|
||||
cell.current = cloned;
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function cloneBuiltInCheck(value: ZodInternalNode): unknown {
|
||||
const definition = value._zod.def;
|
||||
if (typeof definition !== "object" || definition === null) {
|
||||
unsupported("executable");
|
||||
}
|
||||
const checkDefinition = definition as {
|
||||
check?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
if (
|
||||
checkDefinition.check === "custom" ||
|
||||
checkDefinition.type === "custom"
|
||||
) {
|
||||
unsupported("custom");
|
||||
}
|
||||
|
||||
const clonedDefinition = cloneRecord(definition, true);
|
||||
const cloned = new value._zod.constr(clonedDefinition as never) as {
|
||||
_zod?: { check?: unknown };
|
||||
};
|
||||
if (
|
||||
typeof (value._zod as { check?: unknown }).check === "function" &&
|
||||
typeof cloned._zod?.check !== "function"
|
||||
) {
|
||||
unsupported("executable");
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function cloneRecord(value: object, omitBuiltInWhen = false): object {
|
||||
const cached = graphValues.get(value);
|
||||
if (cached !== undefined) return cached as object;
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
const cloned = Object.create(prototype) as Record<PropertyKey, unknown>;
|
||||
graphValues.set(value, cloned);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor) continue;
|
||||
if (
|
||||
omitBuiltInWhen &&
|
||||
key === "when" &&
|
||||
"value" in descriptor &&
|
||||
typeof descriptor.value === "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved: unknown;
|
||||
if ("value" in descriptor) {
|
||||
resolved = descriptor.value;
|
||||
} else if (descriptor.get) {
|
||||
try {
|
||||
resolved = Reflect.get(value, key);
|
||||
} catch {
|
||||
unsupported("executable");
|
||||
}
|
||||
} else {
|
||||
unsupported("executable");
|
||||
}
|
||||
Object.defineProperty(cloned, key, {
|
||||
configurable: descriptor.configurable,
|
||||
enumerable: descriptor.enumerable,
|
||||
value: cloneGraph(resolved),
|
||||
writable: "writable" in descriptor ? descriptor.writable : false,
|
||||
});
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function cloneGraph(value: unknown): unknown {
|
||||
if (typeof value === "function") unsupported("executable");
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
if (value instanceof z.ZodType) return cloneSchema(value);
|
||||
|
||||
const cached = graphValues.get(value);
|
||||
if (cached !== undefined) return cached;
|
||||
if (isZodInternalNode(value)) return cloneBuiltInCheck(value);
|
||||
if (Array.isArray(value)) {
|
||||
const cloned: unknown[] = [];
|
||||
graphValues.set(value, cloned);
|
||||
for (const item of value) cloned.push(cloneGraph(item));
|
||||
return cloned;
|
||||
}
|
||||
if (value instanceof RegExp) {
|
||||
const cloned = new RegExp(value.source, value.flags);
|
||||
graphValues.set(value, cloned);
|
||||
return cloned;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
const cloned = new Date(value.getTime());
|
||||
graphValues.set(value, cloned);
|
||||
return cloned;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
const cloned = new Map<unknown, unknown>();
|
||||
graphValues.set(value, cloned);
|
||||
for (const [key, item] of value) {
|
||||
cloned.set(cloneGraph(key), cloneGraph(item));
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const cloned = new Set<unknown>();
|
||||
graphValues.set(value, cloned);
|
||||
for (const item of value) cloned.add(cloneGraph(item));
|
||||
return cloned;
|
||||
}
|
||||
return cloneRecord(value);
|
||||
}
|
||||
|
||||
return createReadonlyValidationFacade(cloneSchema(schema));
|
||||
}
|
||||
|
||||
function isZodInternalNode(value: object): value is ZodInternalNode {
|
||||
const internal = (value as { _zod?: unknown })._zod;
|
||||
return (
|
||||
typeof internal === "object" &&
|
||||
internal !== null &&
|
||||
typeof (internal as { constr?: unknown }).constr === "function" &&
|
||||
"def" in internal
|
||||
);
|
||||
}
|
||||
|
||||
type ReadonlyIntrinsicMethod = (...args: never[]) => unknown;
|
||||
|
||||
const MAP_READ_METHODS = new Map<PropertyKey, ReadonlyIntrinsicMethod>([
|
||||
["get", Map.prototype.get as unknown as ReadonlyIntrinsicMethod],
|
||||
["has", Map.prototype.has as unknown as ReadonlyIntrinsicMethod],
|
||||
["entries", Map.prototype.entries as unknown as ReadonlyIntrinsicMethod],
|
||||
["keys", Map.prototype.keys as unknown as ReadonlyIntrinsicMethod],
|
||||
["values", Map.prototype.values as unknown as ReadonlyIntrinsicMethod],
|
||||
[
|
||||
Symbol.iterator,
|
||||
Map.prototype[Symbol.iterator] as unknown as ReadonlyIntrinsicMethod,
|
||||
],
|
||||
]);
|
||||
const MAP_SIZE_GETTER = Object.getOwnPropertyDescriptor(Map.prototype, "size")
|
||||
?.get as ReadonlyIntrinsicMethod | undefined;
|
||||
const SET_READ_METHODS = new Map<PropertyKey, ReadonlyIntrinsicMethod>([
|
||||
["has", Set.prototype.has as unknown as ReadonlyIntrinsicMethod],
|
||||
["entries", Set.prototype.entries as unknown as ReadonlyIntrinsicMethod],
|
||||
["keys", Set.prototype.keys as unknown as ReadonlyIntrinsicMethod],
|
||||
["values", Set.prototype.values as unknown as ReadonlyIntrinsicMethod],
|
||||
[
|
||||
Symbol.iterator,
|
||||
Set.prototype[Symbol.iterator] as unknown as ReadonlyIntrinsicMethod,
|
||||
],
|
||||
]);
|
||||
const SET_SIZE_GETTER = Object.getOwnPropertyDescriptor(Set.prototype, "size")
|
||||
?.get as ReadonlyIntrinsicMethod | undefined;
|
||||
const DATE_READ_METHODS = new Map<PropertyKey, ReadonlyIntrinsicMethod>();
|
||||
for (const property of Reflect.ownKeys(Date.prototype)) {
|
||||
if (
|
||||
property === "constructor" ||
|
||||
(typeof property === "string" && property.startsWith("set"))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Date.prototype, property);
|
||||
if (typeof descriptor?.value === "function") {
|
||||
DATE_READ_METHODS.set(
|
||||
property,
|
||||
descriptor.value as ReadonlyIntrinsicMethod,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
||||
const views = new WeakMap<object, unknown>();
|
||||
const dataFirstValidationMethods = new Set<PropertyKey>([
|
||||
"parse",
|
||||
"safeParse",
|
||||
"parseAsync",
|
||||
"safeParseAsync",
|
||||
"spa",
|
||||
"encode",
|
||||
"decode",
|
||||
"encodeAsync",
|
||||
"decodeAsync",
|
||||
"safeEncode",
|
||||
"safeDecode",
|
||||
"safeEncodeAsync",
|
||||
"safeDecodeAsync",
|
||||
]);
|
||||
const parseResultMethods = new Set<PropertyKey>([
|
||||
...dataFirstValidationMethods,
|
||||
"toJSONSchema",
|
||||
]);
|
||||
const unsafeCallbackMessage =
|
||||
"callback-bearing schema arguments are not supported";
|
||||
|
||||
function rejectUnsafeCallback(): never {
|
||||
throw new TypeError(unsafeCallbackMessage);
|
||||
}
|
||||
|
||||
function inspectCallbackValues(
|
||||
source: object,
|
||||
seen: WeakSet<object>,
|
||||
skipConstructor: boolean,
|
||||
): void {
|
||||
let keys: readonly PropertyKey[];
|
||||
try {
|
||||
keys = Reflect.ownKeys(source);
|
||||
} catch {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (skipConstructor && key === "constructor") continue;
|
||||
let descriptor: PropertyDescriptor | undefined;
|
||||
try {
|
||||
descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
} catch {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
if (!descriptor) continue;
|
||||
if (!("value" in descriptor)) rejectUnsafeCallback();
|
||||
assertCallbackFreeValue(descriptor.value, seen);
|
||||
}
|
||||
}
|
||||
|
||||
function isOpaqueValidationArgument(value: object): boolean {
|
||||
try {
|
||||
if (value instanceof z.ZodType) return true;
|
||||
const internalDescriptor = Object.getOwnPropertyDescriptor(value, "_zod");
|
||||
if (!internalDescriptor || !("value" in internalDescriptor)) {
|
||||
return false;
|
||||
}
|
||||
const internal = internalDescriptor.value;
|
||||
return (
|
||||
typeof internal === "object" &&
|
||||
internal !== null &&
|
||||
typeof (internal as { constr?: unknown }).constr === "function" &&
|
||||
"def" in internal
|
||||
);
|
||||
} catch {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
function assertCallbackFreeValue(
|
||||
value: unknown,
|
||||
seen: WeakSet<object>,
|
||||
): void {
|
||||
if (typeof value === "function") rejectUnsafeCallback();
|
||||
if (typeof value !== "object" || value === null || seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
if (isOpaqueValidationArgument(value)) return;
|
||||
|
||||
inspectCallbackValues(value, seen, false);
|
||||
if (
|
||||
Array.isArray(value) ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
value instanceof WeakMap ||
|
||||
value instanceof WeakSet ||
|
||||
value instanceof Date ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Promise ||
|
||||
ArrayBuffer.isView(value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prototype: object | null;
|
||||
try {
|
||||
prototype = Object.getPrototypeOf(value);
|
||||
} catch {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
while (prototype && prototype !== Object.prototype) {
|
||||
inspectCallbackValues(prototype, seen, true);
|
||||
try {
|
||||
prototype = Object.getPrototypeOf(prototype);
|
||||
} catch {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCallbackFreeArguments(args: readonly unknown[]): void {
|
||||
const seen = new WeakSet<object>();
|
||||
for (const argument of args) assertCallbackFreeValue(argument, seen);
|
||||
}
|
||||
|
||||
function readOption(options: object, property: PropertyKey): unknown {
|
||||
try {
|
||||
return Reflect.get(options, property, options);
|
||||
} catch {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
function optionRecord(value: unknown): object {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeParseOptions(value: unknown): object | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const options = optionRecord(value);
|
||||
if (readOption(options, "error") !== undefined) rejectUnsafeCallback();
|
||||
|
||||
const safe = Object.create(null) as Record<string, unknown>;
|
||||
for (const property of ["reportInput", "jitless"] as const) {
|
||||
const option = readOption(options, property);
|
||||
if (option === undefined) continue;
|
||||
if (typeof option !== "boolean") rejectUnsafeCallback();
|
||||
safe[property] = option;
|
||||
}
|
||||
return Object.freeze(safe);
|
||||
}
|
||||
|
||||
function sanitizeJsonSchemaOptions(value: unknown): object | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const options = optionRecord(value);
|
||||
for (const property of [
|
||||
"override",
|
||||
"processors",
|
||||
"metadata",
|
||||
"external",
|
||||
] as const) {
|
||||
if (readOption(options, property) !== undefined) {
|
||||
rejectUnsafeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
const safe = Object.create(null) as Record<string, unknown>;
|
||||
for (const property of [
|
||||
"target",
|
||||
"unrepresentable",
|
||||
"io",
|
||||
"cycles",
|
||||
"reused",
|
||||
] as const) {
|
||||
const option = readOption(options, property);
|
||||
if (option === undefined) continue;
|
||||
if (typeof option !== "string") rejectUnsafeCallback();
|
||||
safe[property] = option;
|
||||
}
|
||||
return Object.freeze(safe);
|
||||
}
|
||||
|
||||
function prepareSafeSchemaMethodArguments(
|
||||
property: PropertyKey,
|
||||
args: readonly unknown[],
|
||||
): readonly unknown[] {
|
||||
assertCallbackFreeArguments(
|
||||
dataFirstValidationMethods.has(property) ? args.slice(1) : args,
|
||||
);
|
||||
if (dataFirstValidationMethods.has(property)) {
|
||||
const options = sanitizeParseOptions(args[1]);
|
||||
return options === undefined ? [args[0]] : [args[0], options];
|
||||
}
|
||||
if (property === "toJSONSchema") {
|
||||
const options = sanitizeJsonSchemaOptions(args[0]);
|
||||
return options === undefined ? [] : [options];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readonlyView(value: unknown): unknown {
|
||||
if (
|
||||
(typeof value !== "object" && typeof value !== "function") ||
|
||||
value === null
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
const cached = views.get(value);
|
||||
if (cached !== undefined) return cached;
|
||||
if (value instanceof z.ZodType) return schemaFacade(value);
|
||||
if (typeof value === "function") {
|
||||
return functionFacade(value as (...args: never[]) => unknown);
|
||||
}
|
||||
if (Array.isArray(value)) return arrayFacade(value);
|
||||
if (value instanceof Map) return mapFacade(value);
|
||||
if (value instanceof Set) return setFacade(value);
|
||||
if (value instanceof RegExp) {
|
||||
const detached = new RegExp(value.source, value.flags);
|
||||
views.set(value, detached);
|
||||
return Object.freeze(detached);
|
||||
}
|
||||
if (value instanceof Date) return dateFacade(value);
|
||||
return objectFacade(value);
|
||||
}
|
||||
|
||||
function functionFacade(value: (...args: never[]) => unknown): unknown {
|
||||
const wrapped = function (this: unknown, ...args: unknown[]) {
|
||||
assertCallbackFreeArguments(args);
|
||||
const result = new.target
|
||||
? Reflect.construct(value, args)
|
||||
: Reflect.apply(value, this, args);
|
||||
return readonlyView(result);
|
||||
};
|
||||
views.set(value, wrapped);
|
||||
return Object.freeze(wrapped);
|
||||
}
|
||||
|
||||
function arrayFacade(value: readonly unknown[]): readonly unknown[] {
|
||||
const detached: unknown[] = [];
|
||||
views.set(value, detached);
|
||||
for (const item of value) detached.push(readonlyView(item));
|
||||
return Object.freeze(detached);
|
||||
}
|
||||
|
||||
const rejectedMutation = Object.freeze((): never => {
|
||||
throw new TypeError("readonly detached view");
|
||||
});
|
||||
|
||||
function mapFacade(
|
||||
value: ReadonlyMap<unknown, unknown>,
|
||||
): ReadonlyMap<unknown, unknown> {
|
||||
const detached = new Map<unknown, unknown>();
|
||||
let facade: ReadonlyMap<unknown, unknown>;
|
||||
facade = new Proxy(detached, {
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
get: (target, property) => {
|
||||
if (
|
||||
property === "set" ||
|
||||
property === "delete" ||
|
||||
property === "clear"
|
||||
) {
|
||||
return rejectedMutation;
|
||||
}
|
||||
if (property === "forEach") {
|
||||
return Object.freeze((callback: unknown, thisArg?: unknown): void => {
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError("Map forEach callback missing");
|
||||
}
|
||||
for (const [key, item] of target) {
|
||||
Reflect.apply(callback, thisArg, [item, key, facade]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (property === "size" && MAP_SIZE_GETTER) {
|
||||
return Reflect.apply(MAP_SIZE_GETTER, target, []);
|
||||
}
|
||||
const intrinsic = MAP_READ_METHODS.get(property);
|
||||
if (intrinsic) {
|
||||
return Object.freeze((...args: unknown[]) =>
|
||||
Reflect.apply(intrinsic, target, args),
|
||||
);
|
||||
}
|
||||
return readonlyView(Reflect.get(target, property, facade));
|
||||
},
|
||||
set: () => false,
|
||||
setPrototypeOf: () => false,
|
||||
});
|
||||
views.set(value, facade);
|
||||
views.set(facade, facade);
|
||||
for (const [key, item] of value) {
|
||||
detached.set(readonlyView(key), readonlyView(item));
|
||||
}
|
||||
Object.freeze(detached);
|
||||
return facade;
|
||||
}
|
||||
|
||||
function setFacade(value: ReadonlySet<unknown>): ReadonlySet<unknown> {
|
||||
const detached = new Set<unknown>();
|
||||
let facade: ReadonlySet<unknown>;
|
||||
facade = new Proxy(detached, {
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
get: (target, property) => {
|
||||
if (
|
||||
property === "add" ||
|
||||
property === "delete" ||
|
||||
property === "clear"
|
||||
) {
|
||||
return rejectedMutation;
|
||||
}
|
||||
if (property === "forEach") {
|
||||
return Object.freeze((callback: unknown, thisArg?: unknown): void => {
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError("Set forEach callback missing");
|
||||
}
|
||||
for (const item of target) {
|
||||
Reflect.apply(callback, thisArg, [item, item, facade]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (property === "size" && SET_SIZE_GETTER) {
|
||||
return Reflect.apply(SET_SIZE_GETTER, target, []);
|
||||
}
|
||||
const intrinsic = SET_READ_METHODS.get(property);
|
||||
if (intrinsic) {
|
||||
return Object.freeze((...args: unknown[]) =>
|
||||
Reflect.apply(intrinsic, target, args),
|
||||
);
|
||||
}
|
||||
return readonlyView(Reflect.get(target, property, facade));
|
||||
},
|
||||
set: () => false,
|
||||
setPrototypeOf: () => false,
|
||||
});
|
||||
views.set(value, facade);
|
||||
views.set(facade, facade);
|
||||
for (const item of value) detached.add(readonlyView(item));
|
||||
Object.freeze(detached);
|
||||
return facade;
|
||||
}
|
||||
|
||||
function dateFacade(value: Date): Date {
|
||||
const detached = new Date(value.getTime());
|
||||
const facade = new Proxy(detached, {
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
get: (target, property) => {
|
||||
if (typeof property === "string" && property.startsWith("set")) {
|
||||
return rejectedMutation;
|
||||
}
|
||||
const intrinsic = DATE_READ_METHODS.get(property);
|
||||
if (intrinsic) {
|
||||
return Object.freeze((...args: unknown[]) =>
|
||||
Reflect.apply(intrinsic, target, args),
|
||||
);
|
||||
}
|
||||
return readonlyView(Reflect.get(target, property, facade));
|
||||
},
|
||||
set: () => false,
|
||||
setPrototypeOf: () => false,
|
||||
});
|
||||
views.set(value, facade);
|
||||
views.set(facade, facade);
|
||||
Object.freeze(detached);
|
||||
return facade;
|
||||
}
|
||||
|
||||
function objectFacade(value: object): object {
|
||||
const detached = Object.create(Object.getPrototypeOf(value)) as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
views.set(value, detached);
|
||||
copyReadonlyProperties(value, detached, (method) =>
|
||||
boundFunctionFacade(method, value),
|
||||
);
|
||||
return Object.freeze(detached);
|
||||
}
|
||||
|
||||
function boundFunctionFacade(
|
||||
method: (...args: never[]) => unknown,
|
||||
receiver: object,
|
||||
): (...args: unknown[]) => unknown {
|
||||
return Object.freeze((...args: unknown[]) => {
|
||||
assertCallbackFreeArguments(args);
|
||||
return readonlyView(Reflect.apply(method, receiver, args));
|
||||
});
|
||||
}
|
||||
|
||||
function copyReadonlyProperties(
|
||||
source: object,
|
||||
destination: Record<PropertyKey, unknown>,
|
||||
wrapMethod: (
|
||||
method: (...args: never[]) => unknown,
|
||||
property: PropertyKey,
|
||||
) => unknown,
|
||||
): void {
|
||||
for (const key of Reflect.ownKeys(source)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor) continue;
|
||||
if ("value" in descriptor) {
|
||||
Object.defineProperty(destination, key, {
|
||||
configurable: false,
|
||||
enumerable: descriptor.enumerable,
|
||||
value:
|
||||
typeof descriptor.value === "function"
|
||||
? wrapMethod(descriptor.value, key)
|
||||
: readonlyView(descriptor.value),
|
||||
writable: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const getter = descriptor.get;
|
||||
const safeGetter = getter
|
||||
? () => readonlyView(Reflect.apply(getter, source, []))
|
||||
: undefined;
|
||||
Object.defineProperty(destination, key, {
|
||||
configurable: false,
|
||||
enumerable: descriptor.enumerable,
|
||||
get: safeGetter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function materializeSchemaPrototypeMethods(target: z.ZodType): void {
|
||||
let prototype = Object.getPrototypeOf(target);
|
||||
while (prototype && prototype !== Object.prototype) {
|
||||
for (const property of Reflect.ownKeys(prototype)) {
|
||||
if (Object.hasOwn(target, property)) continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, property);
|
||||
if (
|
||||
!descriptor?.get ||
|
||||
!descriptor.set ||
|
||||
!descriptor.configurable ||
|
||||
descriptor.enumerable
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
Reflect.get(target, property, target);
|
||||
}
|
||||
prototype = Object.getPrototypeOf(prototype);
|
||||
}
|
||||
}
|
||||
function schemaFacade<S extends z.ZodType>(target: S): S {
|
||||
const cached = views.get(target);
|
||||
if (cached !== undefined) return cached as S;
|
||||
|
||||
materializeSchemaPrototypeMethods(target);
|
||||
const shell = Object.create(Object.getPrototypeOf(target)) as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
let facade: S;
|
||||
facade = new Proxy(shell, {
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
get: (publicShell, property, receiver) => {
|
||||
if (Object.hasOwn(publicShell, property)) {
|
||||
return Reflect.get(publicShell, property, receiver);
|
||||
}
|
||||
const inherited = Reflect.get(publicShell, property, receiver);
|
||||
return readonlyView(inherited);
|
||||
},
|
||||
set: () => false,
|
||||
setPrototypeOf: () => false,
|
||||
}) as unknown as S;
|
||||
views.set(target, facade);
|
||||
copyReadonlyProperties(target, shell, (method, property) =>
|
||||
schemaMethodFacade(method, property, target, facade),
|
||||
);
|
||||
Object.freeze(shell);
|
||||
return facade;
|
||||
}
|
||||
|
||||
function schemaMethodFacade<S extends z.ZodType>(
|
||||
method: (...args: never[]) => unknown,
|
||||
property: PropertyKey,
|
||||
target: S,
|
||||
facade: S,
|
||||
): (...args: unknown[]) => unknown {
|
||||
if (property === "apply") {
|
||||
return Object.freeze((...args: unknown[]) => {
|
||||
const callback = args[0];
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError("schema apply callback missing");
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [facade]);
|
||||
});
|
||||
}
|
||||
if (property === "register") {
|
||||
return Object.freeze((...args: unknown[]) => {
|
||||
const registry = args[0];
|
||||
if (
|
||||
typeof registry !== "object" ||
|
||||
registry === null ||
|
||||
typeof (registry as { add?: unknown }).add !== "function"
|
||||
) {
|
||||
throw new TypeError("schema registry missing");
|
||||
}
|
||||
(
|
||||
registry as {
|
||||
add(schema: S, metadata?: unknown): unknown;
|
||||
}
|
||||
).add(facade, args[1]);
|
||||
return facade;
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze((...args: unknown[]) => {
|
||||
const safeArgs = prepareSafeSchemaMethodArguments(property, args);
|
||||
const result = Reflect.apply(method, target, safeArgs);
|
||||
if (result instanceof z.ZodType) {
|
||||
return isolateValidationGraph(result, "derived-schema");
|
||||
}
|
||||
return parseResultMethods.has(property) ? result : readonlyView(result);
|
||||
});
|
||||
}
|
||||
|
||||
return schemaFacade(schema);
|
||||
}
|
||||
function isNormalizedIdentifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= 160 &&
|
||||
value === value.normalize("NFC").trim()
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type HousekeepingSearchResult,
|
||||
type HousekeepingWidgetDefinition,
|
||||
type HousekeepingWorkItem,
|
||||
mapUnknownError,
|
||||
ok,
|
||||
} from ".";
|
||||
|
||||
@@ -26,32 +25,18 @@ const workItem = {
|
||||
domain: "people",
|
||||
capability,
|
||||
severity: "warning",
|
||||
priority: "high",
|
||||
ageMs: 60_000,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-24T12:00:00.000Z",
|
||||
titleKey: "pages.housekeeping.items.ticket",
|
||||
context: { ticketId: "42" },
|
||||
href: "/ase/people/tickets/42",
|
||||
href: "/admin-next/people/tickets/42",
|
||||
freshness: "fresh",
|
||||
actions: [
|
||||
{
|
||||
id: "people.ticket.open",
|
||||
labelKey: "pages.housekeeping.items.ticket.open",
|
||||
commandId: "people.ticket.open",
|
||||
capability,
|
||||
},
|
||||
],
|
||||
} satisfies HousekeepingWorkItem;
|
||||
|
||||
const searchResult = {
|
||||
id: "user-42",
|
||||
domain: "people",
|
||||
type: "entity",
|
||||
title: "operator",
|
||||
description: "A Housekeeping operator",
|
||||
href: "/ase/people/users/42",
|
||||
capability,
|
||||
href: "/admin-next/people/users/42",
|
||||
} satisfies HousekeepingSearchResult;
|
||||
|
||||
const searchProvider: HousekeepingSearchProvider = {
|
||||
@@ -102,7 +87,7 @@ const manifest: HousekeepingDomainManifest = {
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
previewHref: "/admin-next/people",
|
||||
capability,
|
||||
routes: [],
|
||||
searchProviders: [searchProvider],
|
||||
@@ -111,68 +96,19 @@ const manifest: HousekeepingDomainManifest = {
|
||||
};
|
||||
|
||||
describe("housekeeping foundation contracts", () => {
|
||||
it("preserves correlation IDs for successful results", () => {
|
||||
it("creates typed success and error results", () => {
|
||||
expect(ok({ count: 2 }, "corr-1")).toEqual({
|
||||
ok: true,
|
||||
data: { count: 2 },
|
||||
correlationId: "corr-1",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"UNAUTHENTICATED",
|
||||
"FORBIDDEN",
|
||||
"VALIDATION",
|
||||
"NOT_FOUND",
|
||||
"CONFLICT",
|
||||
"RATE_LIMITED",
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"TIMEOUT",
|
||||
"INTERNAL",
|
||||
] as const)("returns the approved %s error contract", (code) => {
|
||||
expect(
|
||||
fail(code, `errors.housekeeping.${code.toLowerCase()}`, "corr-2", {
|
||||
name: ["errors.validation.required"],
|
||||
}),
|
||||
).toEqual({
|
||||
expect(fail("CAPABILITY_DENIED", "corr-2")).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
messageKey: `errors.housekeeping.${code.toLowerCase()}`,
|
||||
fieldErrors: { name: ["errors.validation.required"] },
|
||||
},
|
||||
error: { code: "CAPABILITY_DENIED" },
|
||||
correlationId: "corr-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a supplied correlation ID when mapping an unknown error", () => {
|
||||
expect(
|
||||
mapUnknownError(new Error("database password exposed"), "corr-unknown"),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL",
|
||||
messageKey: "errors.housekeeping.internal",
|
||||
},
|
||||
correlationId: "corr-unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates a correlation ID when mapping an unknown error without one", () => {
|
||||
const result = mapUnknownError(new Error("database password exposed"));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL",
|
||||
messageKey: "errors.housekeeping.internal",
|
||||
},
|
||||
});
|
||||
expect(result.correlationId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty capability requirements", () => {
|
||||
expect(() => anyCapability()).toThrow("capability requirement is empty");
|
||||
expect(() => allCapabilities()).toThrow("capability requirement is empty");
|
||||
|
||||
@@ -4,11 +4,10 @@ import type { HousekeepingInboxSource } from "./inbox";
|
||||
import type { HousekeepingSearchProvider } from "./search";
|
||||
import type { HousekeepingWidgetDefinition } from "./widget";
|
||||
|
||||
export type CanonicalHousekeepingHref = "/ase" | `/ase/${string}`;
|
||||
export interface HousekeepingRouteDefinition {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
href: CanonicalHousekeepingHref;
|
||||
href: string;
|
||||
capability: CapabilityRequirement;
|
||||
matchPrefixes?: readonly string[];
|
||||
}
|
||||
@@ -18,7 +17,7 @@ export interface HousekeepingDomainManifest {
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
|
||||
canonicalHref: CanonicalHousekeepingHref;
|
||||
previewHref: `/admin-next/${HousekeepingDomainId}`;
|
||||
capability: CapabilityRequirement;
|
||||
routes: readonly HousekeepingRouteDefinition[];
|
||||
searchProviders: readonly HousekeepingSearchProvider[];
|
||||
|
||||
@@ -3,25 +3,12 @@ import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "./capability";
|
||||
import type { CanonicalHousekeepingHref } from "./domain";
|
||||
import type { HousekeepingResult } from "./result";
|
||||
|
||||
export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical";
|
||||
export type HousekeepingWorkItemFreshness = "fresh" | "stale";
|
||||
export type HousekeepingWorkItemPriority =
|
||||
| "low"
|
||||
| "normal"
|
||||
| "high"
|
||||
| "critical";
|
||||
export type HousekeepingInboxSourceAvailability = "available" | "unavailable";
|
||||
|
||||
export interface HousekeepingWorkItemAction {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
commandId: string;
|
||||
capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
export interface HousekeepingWorkItem {
|
||||
sourceId: string;
|
||||
itemId: string;
|
||||
@@ -29,15 +16,11 @@ export interface HousekeepingWorkItem {
|
||||
domain: HousekeepingDomainId;
|
||||
capability: CapabilityRequirement;
|
||||
severity: HousekeepingWorkItemSeverity;
|
||||
priority: HousekeepingWorkItemPriority;
|
||||
ageMs: number;
|
||||
state: string;
|
||||
occurredAt: string;
|
||||
titleKey: string;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
href: CanonicalHousekeepingHref;
|
||||
href: string;
|
||||
freshness: HousekeepingWorkItemFreshness;
|
||||
actions: readonly HousekeepingWorkItemAction[];
|
||||
}
|
||||
|
||||
export interface HousekeepingInboxSourceResult {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { createCorrelationId } from "../correlation";
|
||||
export {
|
||||
allCapabilities,
|
||||
anyCapability,
|
||||
@@ -8,7 +7,6 @@ export {
|
||||
} from "./capability";
|
||||
export type { HousekeepingCommand } from "./command";
|
||||
export type {
|
||||
CanonicalHousekeepingHref,
|
||||
HousekeepingDomainManifest,
|
||||
HousekeepingRouteDefinition,
|
||||
} from "./domain";
|
||||
@@ -17,26 +15,20 @@ export type {
|
||||
HousekeepingInboxSourceAvailability,
|
||||
HousekeepingInboxSourceResult,
|
||||
HousekeepingWorkItem,
|
||||
HousekeepingWorkItemAction,
|
||||
HousekeepingWorkItemFreshness,
|
||||
HousekeepingWorkItemPriority,
|
||||
HousekeepingWorkItemSeverity,
|
||||
} from "./inbox";
|
||||
export type { HousekeepingQuery } from "./query";
|
||||
export {
|
||||
fail,
|
||||
type HousekeepingError,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingPartialCompletion,
|
||||
type HousekeepingResult,
|
||||
mapUnknownError,
|
||||
ok,
|
||||
} from "./result";
|
||||
export type {
|
||||
HousekeepingSearchInput,
|
||||
HousekeepingSearchProvider,
|
||||
HousekeepingSearchResult,
|
||||
HousekeepingSearchResultType,
|
||||
} from "./search";
|
||||
export type {
|
||||
HousekeepingWidgetDefinition,
|
||||
|
||||
@@ -1,70 +1,28 @@
|
||||
import { createCorrelationId } from "../correlation";
|
||||
|
||||
export type HousekeepingErrorCode =
|
||||
| "UNAUTHENTICATED"
|
||||
| "FORBIDDEN"
|
||||
| "VALIDATION"
|
||||
| "VALIDATION_FAILED"
|
||||
| "AUTHENTICATION_REQUIRED"
|
||||
| "CAPABILITY_DENIED"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "RATE_LIMITED"
|
||||
| "DEPENDENCY_UNAVAILABLE"
|
||||
| "TIMEOUT"
|
||||
| "INTERNAL";
|
||||
|
||||
export interface HousekeepingError {
|
||||
code: HousekeepingErrorCode;
|
||||
messageKey: string;
|
||||
fieldErrors?: Readonly<Record<string, readonly string[]>>;
|
||||
}
|
||||
|
||||
export interface HousekeepingPartialCompletion {
|
||||
readonly status: "partial";
|
||||
readonly external: "not-required" | "completed" | "failed";
|
||||
readonly audit: "persisted" | "unavailable";
|
||||
}
|
||||
| "PARTIAL_RESULT"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export type HousekeepingResult<T> =
|
||||
| { ok: true; data: T; correlationId: string }
|
||||
| {
|
||||
ok: true;
|
||||
data: T;
|
||||
ok: false;
|
||||
error: { code: HousekeepingErrorCode };
|
||||
correlationId: string;
|
||||
completion?: HousekeepingPartialCompletion;
|
||||
}
|
||||
| { ok: false; error: HousekeepingError; correlationId: string };
|
||||
};
|
||||
|
||||
export function ok<T>(
|
||||
data: T,
|
||||
correlationId: string,
|
||||
completion?: HousekeepingPartialCompletion,
|
||||
): HousekeepingResult<T> {
|
||||
return {
|
||||
ok: true,
|
||||
data,
|
||||
correlationId,
|
||||
...(completion === undefined ? {} : { completion }),
|
||||
};
|
||||
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
||||
return { ok: true, data, correlationId };
|
||||
}
|
||||
|
||||
export function fail(
|
||||
code: HousekeepingErrorCode,
|
||||
messageKey: string,
|
||||
correlationId: string,
|
||||
fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
): HousekeepingResult<never> {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
messageKey,
|
||||
...(fieldErrors === undefined ? {} : { fieldErrors }),
|
||||
},
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUnknownError(
|
||||
_error: unknown,
|
||||
correlationId = createCorrelationId(),
|
||||
): HousekeepingResult<never> {
|
||||
return fail("INTERNAL", "errors.housekeeping.internal", correlationId);
|
||||
return { ok: false, error: { code }, correlationId };
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "./capability";
|
||||
import type { CanonicalHousekeepingHref } from "./domain";
|
||||
import type { HousekeepingResult } from "./result";
|
||||
|
||||
export interface HousekeepingSearchInput {
|
||||
@@ -11,16 +10,11 @@ export interface HousekeepingSearchInput {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type HousekeepingSearchResultType = "navigation" | "entity" | "command";
|
||||
|
||||
export interface HousekeepingSearchResult {
|
||||
id: string;
|
||||
domain: HousekeepingDomainId;
|
||||
type: HousekeepingSearchResultType;
|
||||
title: string;
|
||||
description?: string;
|
||||
href: CanonicalHousekeepingHref;
|
||||
capability: CapabilityRequirement;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingSearchProvider {
|
||||
|
||||
@@ -14,6 +14,5 @@ export interface HousekeepingWidgetDefinition {
|
||||
kind: HousekeepingWidgetKind;
|
||||
load(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<HousekeepingResult<unknown>>;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCorrelationId } from "./correlation";
|
||||
|
||||
describe("createCorrelationId", () => {
|
||||
it("creates UUID-backed identifiers within the audit storage limit", () => {
|
||||
const correlationId = createCorrelationId();
|
||||
|
||||
expect(correlationId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(correlationId.length).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
it("does not reuse an identifier for separate operations", () => {
|
||||
expect(createCorrelationId()).not.toBe(createCorrelationId());
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function createCorrelationId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,69 +0,0 @@
|
||||
const HOUSEKEEPING_ORIGIN = "https://housekeeping.invalid";
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code < 32 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function decodedVariants(value: string): readonly string[] | null {
|
||||
const variants = [value];
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(variants.at(-1) ?? "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded === variants.at(-1)) break;
|
||||
variants.push(decoded);
|
||||
}
|
||||
return variants;
|
||||
}
|
||||
|
||||
function containsDotSegment(path: string): boolean {
|
||||
return path.split("/").some((segment) => segment === "." || segment === "..");
|
||||
}
|
||||
|
||||
export function isSafeHousekeepingHref(href: string): boolean {
|
||||
if (
|
||||
href.length === 0 ||
|
||||
!href.startsWith("/") ||
|
||||
href.startsWith("//") ||
|
||||
containsControlCharacter(href) ||
|
||||
href.includes("\\")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const variants = decodedVariants(href);
|
||||
if (
|
||||
variants === null ||
|
||||
variants.some(
|
||||
(value) => containsControlCharacter(value) || value.includes("\\"),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawPath = href.split(/[?#]/u, 1)[0] ?? "";
|
||||
const pathVariants = decodedVariants(rawPath);
|
||||
if (
|
||||
pathVariants === null ||
|
||||
pathVariants.some((path) => containsDotSegment(path))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized: URL;
|
||||
try {
|
||||
normalized = new URL(href, HOUSEKEEPING_ORIGIN);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.origin === HOUSEKEEPING_ORIGIN &&
|
||||
(normalized.pathname === "/ase" || normalized.pathname.startsWith("/ase/"))
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import { createHousekeepingInboxService, INBOX_POLICY } from "./inbox-service";
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
function item(
|
||||
itemId: string,
|
||||
options: Partial<HousekeepingWorkItem> = {},
|
||||
): HousekeepingWorkItem {
|
||||
return {
|
||||
sourceId: "people.queue",
|
||||
itemId,
|
||||
deduplicationKey: `people.queue:${itemId}`,
|
||||
domain: "people",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
severity: "info",
|
||||
priority: "normal",
|
||||
ageMs: 1_000,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-30T10:00:00.000Z",
|
||||
titleKey: "pages.housekeeping.items.test",
|
||||
href: "/ase/people/users",
|
||||
freshness: "fresh",
|
||||
actions: [],
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function source(
|
||||
id: string,
|
||||
permission: string,
|
||||
getItems: HousekeepingInboxSource["getItems"],
|
||||
owner: "people" | "content" = "people",
|
||||
): HousekeepingInboxSource {
|
||||
return {
|
||||
id,
|
||||
owner,
|
||||
capability: anyCapability(permission),
|
||||
getItems,
|
||||
};
|
||||
}
|
||||
|
||||
function manifest(
|
||||
id: "people" | "content",
|
||||
permission: string,
|
||||
inboxSources: readonly HousekeepingInboxSource[],
|
||||
): HousekeepingDomainManifest {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.domains.${id}.title`,
|
||||
descriptionKey: `pages.housekeeping.domains.${id}.description`,
|
||||
iconId: id === "people" ? "users" : "file-text",
|
||||
canonicalHref: `/ase/${id}`,
|
||||
capability: anyCapability(permission),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources,
|
||||
widgets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function registry(
|
||||
...domains: readonly HousekeepingDomainManifest[]
|
||||
): HousekeepingRegistry {
|
||||
return { domains };
|
||||
}
|
||||
|
||||
describe("Housekeeping inbox service", () => {
|
||||
it("locks timeout, cap, and deduplication policy", () => {
|
||||
expect(INBOX_POLICY).toEqual({
|
||||
timeoutMs: 2_000,
|
||||
combinedLimit: 200,
|
||||
dedupe: "sourceId:itemId",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters domain and source capabilities before invoking sources", async () => {
|
||||
const permitted = vi.fn(async () =>
|
||||
ok({ availability: "available" as const, items: [] }, "permitted"),
|
||||
);
|
||||
const forbiddenSource = vi.fn(async () =>
|
||||
ok({ availability: "available" as const, items: [] }, "source"),
|
||||
);
|
||||
const forbiddenDomain = vi.fn(async () =>
|
||||
ok({ availability: "available" as const, items: [] }, "domain"),
|
||||
);
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.ready", PERMS.USERS_VIEW, permitted),
|
||||
source("people.bans", PERMS.BANS_VIEW, forbiddenSource),
|
||||
]),
|
||||
manifest("content", PERMS.NEWS_VIEW, [
|
||||
source("content.users", PERMS.USERS_VIEW, forbiddenDomain, "content"),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-capabilities",
|
||||
});
|
||||
|
||||
await service(context([PERMS.USERS_VIEW]));
|
||||
expect(permitted).toHaveBeenCalledOnce();
|
||||
expect(forbiddenSource).not.toHaveBeenCalled();
|
||||
expect(forbiddenDomain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates by source/item and orders by priority then oldest age", async () => {
|
||||
const items = [
|
||||
item("normal", { priority: "normal", ageMs: 900 }),
|
||||
item("high-new", { priority: "high", ageMs: 100 }),
|
||||
item("critical", { priority: "critical", ageMs: 10 }),
|
||||
item("high-old", { priority: "high", ageMs: 1_000 }),
|
||||
item("low", { priority: "low", ageMs: 5_000 }),
|
||||
item("high-old", { priority: "high", ageMs: 999 }),
|
||||
];
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.queue", PERMS.USERS_VIEW, async () =>
|
||||
ok({ availability: "available", items }, "ordered"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-order",
|
||||
});
|
||||
|
||||
const response = await service(context([PERMS.USERS_VIEW]));
|
||||
expect(response.items.map((entry) => entry.itemId)).toEqual([
|
||||
"critical",
|
||||
"high-old",
|
||||
"high-new",
|
||||
"normal",
|
||||
"low",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters item capabilities before enforcing the 200-item cap", async () => {
|
||||
const items = Array.from({ length: 230 }, (_, index) =>
|
||||
item(String(index), {
|
||||
capability: anyCapability(
|
||||
index < 20 ? PERMS.BANS_VIEW : PERMS.USERS_VIEW,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.queue", PERMS.USERS_VIEW, async () =>
|
||||
ok({ availability: "available", items }, "capped"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-cap",
|
||||
});
|
||||
|
||||
const response = await service(context([PERMS.USERS_VIEW]));
|
||||
expect(response.items).toHaveLength(200);
|
||||
expect(response.items[0]?.itemId).toBe("20");
|
||||
expect(response.items.at(-1)?.itemId).toBe("219");
|
||||
});
|
||||
|
||||
it("returns successful items with stable timeout and source errors", async () => {
|
||||
let timeoutSignal: AbortSignal | undefined;
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.timeout", PERMS.USERS_VIEW, (_context, signal) => {
|
||||
timeoutSignal = signal;
|
||||
return new Promise(() => undefined);
|
||||
}),
|
||||
source("people.failed", PERMS.USERS_VIEW, async () =>
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"failed",
|
||||
),
|
||||
),
|
||||
source("people.ready", PERMS.USERS_VIEW, async () =>
|
||||
ok(
|
||||
{
|
||||
availability: "available",
|
||||
items: [item("ready", { sourceId: "people.ready" })],
|
||||
},
|
||||
"ready",
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-partial",
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
const response = await service(context([PERMS.USERS_VIEW]));
|
||||
expect(response.items.map((entry) => entry.itemId)).toEqual(["ready"]);
|
||||
expect(response.errors).toEqual([
|
||||
{ sourceId: "people.timeout", code: "TIMEOUT" },
|
||||
{ sourceId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" },
|
||||
]);
|
||||
expect(timeoutSignal?.aborted).toBe(true);
|
||||
expect(response.correlationId).toBe("inbox-partial");
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import { isSafeHousekeepingHref } from "../housekeeping-href";
|
||||
import {
|
||||
type HousekeepingProvider,
|
||||
runProvider,
|
||||
} from "../providers/run-provider";
|
||||
import {
|
||||
createHousekeepingRegistry,
|
||||
type HousekeepingRegistry,
|
||||
} from "../registry";
|
||||
|
||||
export const INBOX_POLICY = Object.freeze({
|
||||
timeoutMs: 2_000,
|
||||
combinedLimit: 200,
|
||||
dedupe: "sourceId:itemId",
|
||||
} as const);
|
||||
|
||||
export interface HousekeepingInboxResponse {
|
||||
readonly items: readonly HousekeepingWorkItem[];
|
||||
readonly errors: readonly {
|
||||
readonly sourceId: string;
|
||||
readonly code: HousekeepingErrorCode;
|
||||
}[];
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingInboxServiceDependencies {
|
||||
readonly registry: HousekeepingRegistry;
|
||||
readonly createCorrelationId?: () => string;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
const priorityRank = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
} as const;
|
||||
|
||||
const severities = new Set(["info", "warning", "critical"]);
|
||||
const priorities = new Set(Object.keys(priorityRank));
|
||||
const freshnessValues = new Set(["fresh", "stale"]);
|
||||
|
||||
function hasUsableCapability(
|
||||
value: unknown,
|
||||
): value is HousekeepingWorkItem["capability"] {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const capability = value as { mode?: unknown; slugs?: unknown };
|
||||
return (
|
||||
(capability.mode === "any" || capability.mode === "all") &&
|
||||
Array.isArray(capability.slugs) &&
|
||||
capability.slugs.length > 0 &&
|
||||
capability.slugs.every((slug) => typeof slug === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isValidWorkItem(
|
||||
item: HousekeepingWorkItem,
|
||||
source: HousekeepingInboxSource,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): boolean {
|
||||
return (
|
||||
item !== null &&
|
||||
typeof item === "object" &&
|
||||
item.sourceId === source.id &&
|
||||
item.domain === source.owner &&
|
||||
typeof item.itemId === "string" &&
|
||||
item.itemId.trim().length > 0 &&
|
||||
typeof item.deduplicationKey === "string" &&
|
||||
item.deduplicationKey.trim().length > 0 &&
|
||||
severities.has(item.severity) &&
|
||||
priorities.has(item.priority) &&
|
||||
Number.isFinite(item.ageMs) &&
|
||||
item.ageMs >= 0 &&
|
||||
typeof item.state === "string" &&
|
||||
item.state.trim().length > 0 &&
|
||||
typeof item.occurredAt === "string" &&
|
||||
Number.isFinite(Date.parse(item.occurredAt)) &&
|
||||
typeof item.titleKey === "string" &&
|
||||
item.titleKey.trim().length > 0 &&
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
freshnessValues.has(item.freshness) &&
|
||||
Array.isArray(item.actions) &&
|
||||
hasUsableCapability(item.capability) &&
|
||||
satisfiesCapability(context, item.capability)
|
||||
);
|
||||
}
|
||||
|
||||
function isPermittedAction(
|
||||
action: HousekeepingWorkItem["actions"][number],
|
||||
context: HousekeepingCapabilityContext,
|
||||
): boolean {
|
||||
return (
|
||||
action !== null &&
|
||||
typeof action === "object" &&
|
||||
typeof action.id === "string" &&
|
||||
action.id.trim().length > 0 &&
|
||||
typeof action.labelKey === "string" &&
|
||||
action.labelKey.trim().length > 0 &&
|
||||
typeof action.commandId === "string" &&
|
||||
action.commandId.trim().length > 0 &&
|
||||
hasUsableCapability(action.capability) &&
|
||||
satisfiesCapability(context, action.capability)
|
||||
);
|
||||
}
|
||||
|
||||
function sourceProvider(
|
||||
source: HousekeepingInboxSource,
|
||||
): HousekeepingProvider<HousekeepingWorkItem> {
|
||||
return {
|
||||
id: source.id,
|
||||
capability: source.capability,
|
||||
async run(context, signal) {
|
||||
const result = await source.getItems(context, signal);
|
||||
if (!result.ok) return result;
|
||||
if (result.data.availability !== "available") {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
result.correlationId,
|
||||
);
|
||||
}
|
||||
return ok(result.data.items, result.correlationId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createHousekeepingInboxService({
|
||||
registry,
|
||||
createCorrelationId: correlationIdFactory = createCorrelationId,
|
||||
timeoutMs = INBOX_POLICY.timeoutMs,
|
||||
}: HousekeepingInboxServiceDependencies) {
|
||||
return async function loadInbox(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingInboxResponse> {
|
||||
const sources = registry.domains.flatMap((domain) => {
|
||||
if (!satisfiesCapability(context, domain.capability)) return [];
|
||||
return domain.inboxSources.filter((source) =>
|
||||
satisfiesCapability(context, source.capability),
|
||||
);
|
||||
});
|
||||
const outcomes = await Promise.all(
|
||||
sources.map((source) =>
|
||||
runProvider(sourceProvider(source), context, timeoutMs),
|
||||
),
|
||||
);
|
||||
const sourceById = new Map(sources.map((source) => [source.id, source]));
|
||||
const seen = new Set<string>();
|
||||
const items: HousekeepingWorkItem[] = [];
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
const source = sourceById.get(outcome.providerId);
|
||||
if (!source) continue;
|
||||
for (const item of outcome.items) {
|
||||
if (!isValidWorkItem(item, source, context)) continue;
|
||||
const key = JSON.stringify([item.sourceId, item.itemId]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push({
|
||||
...item,
|
||||
actions: item.actions.filter((action) =>
|
||||
isPermittedAction(action, context),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((left, right) => {
|
||||
const priority =
|
||||
priorityRank[left.priority] - priorityRank[right.priority];
|
||||
if (priority !== 0) return priority;
|
||||
return right.ageMs - left.ageMs;
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.slice(0, INBOX_POLICY.combinedLimit),
|
||||
errors: outcomes.flatMap((outcome) =>
|
||||
outcome.error
|
||||
? [{ sourceId: outcome.providerId, code: outcome.error }]
|
||||
: [],
|
||||
),
|
||||
correlationId: correlationIdFactory(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadHousekeepingInbox(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingInboxResponse> {
|
||||
const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests");
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||
});
|
||||
return service(context);
|
||||
}
|
||||
@@ -1,256 +1,116 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ar from "@/messages/ar.json";
|
||||
import bg from "@/messages/bg.json";
|
||||
import cs from "@/messages/cs.json";
|
||||
import da from "@/messages/da.json";
|
||||
import de from "@/messages/de.json";
|
||||
import el from "@/messages/el.json";
|
||||
import en from "@/messages/en.json";
|
||||
import es from "@/messages/es.json";
|
||||
import fi from "@/messages/fi.json";
|
||||
import fr from "@/messages/fr.json";
|
||||
import hr from "@/messages/hr.json";
|
||||
import hu from "@/messages/hu.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
import ja from "@/messages/ja.json";
|
||||
import nl from "@/messages/nl.json";
|
||||
import no from "@/messages/no.json";
|
||||
import pl from "@/messages/pl.json";
|
||||
import pt from "@/messages/pt.json";
|
||||
import ro from "@/messages/ro.json";
|
||||
import ru from "@/messages/ru.json";
|
||||
import sk from "@/messages/sk.json";
|
||||
import sr from "@/messages/sr.json";
|
||||
import sv from "@/messages/sv.json";
|
||||
import tr from "@/messages/tr.json";
|
||||
import uk from "@/messages/uk.json";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
|
||||
const locales = {
|
||||
ar,
|
||||
bg,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hr,
|
||||
hu,
|
||||
it: itMessages,
|
||||
ja,
|
||||
nl,
|
||||
no,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
tr,
|
||||
uk,
|
||||
} as const;
|
||||
|
||||
const commandFiles = [
|
||||
"domains/content/commands/content-commands.ts",
|
||||
"domains/economy/commands/economy-commands.ts",
|
||||
"domains/hotel/commands/hotel-commands.ts",
|
||||
"domains/hotel/commands/studio-commands.ts",
|
||||
"domains/people/commands/user-commands.ts",
|
||||
"domains/people/commands/community-commands.ts",
|
||||
"domains/people/commands/support-commands.ts",
|
||||
"domains/people/commands/moderation-commands.ts",
|
||||
"domains/system/commands/system-commands.ts",
|
||||
] as const;
|
||||
|
||||
const commandIds = [
|
||||
...new Set(
|
||||
commandFiles.flatMap((path) => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "src/features/housekeeping", path),
|
||||
"utf8",
|
||||
);
|
||||
return [
|
||||
...source.matchAll(
|
||||
/"((?:content|economy|hotel|people|system)(?:\.[a-z0-9-]+)+)"/gu,
|
||||
),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((id): id is string => id !== undefined);
|
||||
}),
|
||||
),
|
||||
].sort();
|
||||
|
||||
const staticKeys = [
|
||||
"preview.badge",
|
||||
"preview.commandDisabled",
|
||||
"preview.backToSite",
|
||||
"navigation.skipToContent",
|
||||
"navigation.primary",
|
||||
"navigation.contextual",
|
||||
"navigation.operatorRank",
|
||||
"commandDeck.placeholder",
|
||||
"commandDeck.navigation",
|
||||
"commandDeck.commands",
|
||||
"commandDeck.entities",
|
||||
"commandDeck.loading",
|
||||
"commandDeck.empty",
|
||||
"commandDeck.partial",
|
||||
"commandDeck.close",
|
||||
"workspace.title",
|
||||
"workspace.description",
|
||||
"workspace.noDomainsTitle",
|
||||
"workspace.noDomainsDescription",
|
||||
"workspace.partial",
|
||||
"workspace.inbox.title",
|
||||
"workspace.inbox.domainFilter",
|
||||
"workspace.inbox.stateFilter",
|
||||
"workspace.inbox.priorityFilter",
|
||||
"workspace.inbox.all",
|
||||
"workspace.inbox.loading",
|
||||
"workspace.inbox.empty",
|
||||
"workspace.inbox.partial",
|
||||
"workspace.inbox.open",
|
||||
"workspace.recent.title",
|
||||
"workspace.recent.empty",
|
||||
"workspace.favorites.title",
|
||||
"workspace.favorites.pin",
|
||||
"workspace.favorites.unpin",
|
||||
"workspace.favorites.error",
|
||||
"workspace.favorites.reorderInstructions",
|
||||
"workspace.widgetSettings.title",
|
||||
"workspace.widgetSettings.mandatory",
|
||||
"workspace.widgetSettings.error",
|
||||
"workspace.widgets.title",
|
||||
"workspace.widgets.partial",
|
||||
"workspace.widgets.error",
|
||||
"workspace.widgets.reorderInstructions",
|
||||
] as const;
|
||||
|
||||
const stateKeys = [
|
||||
"loading",
|
||||
"empty",
|
||||
"partial",
|
||||
"error",
|
||||
"forbidden",
|
||||
"ready",
|
||||
].flatMap((state) => [`states.${state}.title`, `states.${state}.description`]);
|
||||
|
||||
const manifestKeys = HOUSEKEEPING_MANIFESTS.flatMap((manifest) => [
|
||||
manifest.labelKey.slice("pages.housekeeping.".length),
|
||||
manifest.descriptionKey.slice("pages.housekeeping.".length),
|
||||
...manifest.routes.map((route) =>
|
||||
route.labelKey.slice("pages.housekeeping.".length),
|
||||
),
|
||||
...manifest.widgets.map((widget) => `widgets.${widget.id}`),
|
||||
]);
|
||||
|
||||
const requiredKeys = [
|
||||
...staticKeys,
|
||||
...stateKeys,
|
||||
"states.partial.label",
|
||||
...manifestKeys,
|
||||
...commandIds.map((id) => `commands.${id}`),
|
||||
"pages.housekeeping.preview.badge",
|
||||
"pages.housekeeping.preview.commandDisabled",
|
||||
"pages.housekeeping.preview.backToSite",
|
||||
"pages.housekeeping.navigation.skipToContent",
|
||||
"pages.housekeeping.navigation.primary",
|
||||
"pages.housekeeping.navigation.contextual",
|
||||
"pages.housekeeping.states.loading.title",
|
||||
"pages.housekeeping.states.loading.description",
|
||||
"pages.housekeeping.states.empty.title",
|
||||
"pages.housekeeping.states.empty.description",
|
||||
"pages.housekeeping.states.partial.label",
|
||||
"pages.housekeeping.states.partial.title",
|
||||
"pages.housekeeping.states.partial.description",
|
||||
"pages.housekeeping.states.error.title",
|
||||
"pages.housekeeping.states.error.description",
|
||||
];
|
||||
|
||||
const expectedDomainMessages = [
|
||||
{
|
||||
id: "operations",
|
||||
labelKey: "pages.housekeeping.domains.operations.title",
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
labelKey: "pages.housekeeping.domains.content.title",
|
||||
descriptionKey: "pages.housekeeping.domains.content.description",
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
labelKey: "pages.housekeeping.domains.economy.title",
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
labelKey: "pages.housekeeping.domains.hotel.title",
|
||||
descriptionKey: "pages.housekeeping.domains.hotel.description",
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
labelKey: "pages.housekeeping.domains.system.title",
|
||||
descriptionKey: "pages.housekeeping.domains.system.description",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function resolveMessage(messages: unknown, key: string): unknown {
|
||||
return key.split(".").reduce<unknown>((value, segment) => {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
|
||||
return (value as Record<string, unknown>)[segment];
|
||||
}, messages);
|
||||
}
|
||||
|
||||
describe("housekeeping localization contract", () => {
|
||||
it("covers exactly the 25 supported locale files", () => {
|
||||
expect(Object.keys(locales)).toEqual([
|
||||
"ar",
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"fi",
|
||||
"fr",
|
||||
"hr",
|
||||
"hu",
|
||||
"it",
|
||||
"ja",
|
||||
"nl",
|
||||
"no",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sr",
|
||||
"sv",
|
||||
"tr",
|
||||
"uk",
|
||||
it.each([
|
||||
["English", en],
|
||||
["Italian", itMessages],
|
||||
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
|
||||
const housekeeping = resolveMessage(messages, "pages.housekeeping");
|
||||
|
||||
expect(housekeeping).toEqual(expect.any(Object));
|
||||
expect(Object.keys(housekeeping as object)).toEqual([
|
||||
"preview",
|
||||
"navigation",
|
||||
"domains",
|
||||
"states",
|
||||
]);
|
||||
expect(commandIds.length).toBeGreaterThan(100);
|
||||
expect(commandIds.every((id) => !id.endsWith("."))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(Object.entries(locales))(
|
||||
"provides every manifest, route, command, widget, and state key in %s",
|
||||
(locale, messages) => {
|
||||
for (const key of requiredKeys) {
|
||||
const fullKey = `pages.housekeeping.${key}`;
|
||||
const value = resolveMessage(messages, fullKey);
|
||||
expect(value, `${locale}: ${fullKey}`).toEqual(expect.any(String));
|
||||
expect((value as string).trim(), `${locale}: ${fullKey}`).not.toBe("");
|
||||
expect(value, `${locale}: ${fullKey}`).not.toBe(fullKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
for (const key of requiredKeys) {
|
||||
expect(resolveMessage(messages, key), key).toEqual(expect.any(String));
|
||||
}
|
||||
|
||||
it("does not use English shell copy as the fallback for another locale", () => {
|
||||
const operatorKeys = [
|
||||
"preview.backToSite",
|
||||
"commandDeck.navigation",
|
||||
"commandDeck.commands",
|
||||
"commandDeck.entities",
|
||||
"commandDeck.close",
|
||||
"workspace.favorites.pin",
|
||||
"workspace.favorites.unpin",
|
||||
"workspace.widgetSettings.mandatory",
|
||||
] as const;
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
if (locale === "en") continue;
|
||||
expect(messages.pages.housekeeping.preview.badge).not.toBe(
|
||||
en.pages.housekeeping.preview.badge,
|
||||
);
|
||||
expect(messages.pages.housekeeping.states.loading.description).not.toBe(
|
||||
en.pages.housekeeping.states.loading.description,
|
||||
);
|
||||
for (const key of operatorKeys) {
|
||||
expect(
|
||||
resolveMessage(messages.pages.housekeeping, key),
|
||||
`${locale}: pages.housekeeping.${key}`,
|
||||
).not.toBe(resolveMessage(en.pages.housekeeping, key));
|
||||
}
|
||||
for (const [index, expected] of expectedDomainMessages.entries()) {
|
||||
const manifest = HOUSEKEEPING_MANIFESTS[index];
|
||||
|
||||
expect(manifest).toMatchObject(expected);
|
||||
expect(
|
||||
resolveMessage(messages, expected.labelKey),
|
||||
expected.labelKey,
|
||||
).toEqual(expect.any(String));
|
||||
expect(
|
||||
resolveMessage(messages, expected.descriptionKey),
|
||||
expected.descriptionKey,
|
||||
).toEqual(expect.any(String));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps idiomatic Italian shell copy", () => {
|
||||
expect(itMessages.pages.housekeeping.preview.badge).toBe(
|
||||
it("uses idiomatic Italian copy for the housekeeping shell", () => {
|
||||
expect(resolveMessage(itMessages, "pages.housekeeping.preview.badge")).toBe(
|
||||
"Anteprima della struttura",
|
||||
);
|
||||
expect(itMessages.pages.housekeeping.navigation.primary).toBe(
|
||||
"Aree housekeeping",
|
||||
);
|
||||
expect(itMessages.pages.housekeeping.commandDeck.placeholder).toBe(
|
||||
"Cerca navigazione, comandi ed entità",
|
||||
);
|
||||
expect(itMessages.pages.housekeeping.states.loading.description).toBe(
|
||||
"Stiamo preparando gli strumenti di housekeeping disponibili.",
|
||||
);
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.navigation.primary"),
|
||||
).toBe("Aree housekeeping");
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.states.loading.title"),
|
||||
).toBe("Caricamento in corso");
|
||||
expect(
|
||||
resolveMessage(
|
||||
itMessages,
|
||||
"pages.housekeeping.states.loading.description",
|
||||
),
|
||||
).toBe("Stiamo preparando gli strumenti di housekeeping disponibili.");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe("housekeeping navigation", () => {
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
previewHref: "/admin-next/people",
|
||||
capability: anyCapability(PERMS.MOD_CFH_VIEW),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
@@ -35,7 +35,7 @@ describe("housekeeping navigation", () => {
|
||||
labelKey: "pages.housekeeping.domains.economy.title",
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
iconId: "gem",
|
||||
canonicalHref: "/ase/economy",
|
||||
previewHref: "/admin-next/economy",
|
||||
capability: anyCapability(PERMS.CATALOG_VIEW),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
@@ -53,7 +53,7 @@ describe("housekeeping navigation", () => {
|
||||
expect(navigation).toEqual([
|
||||
{
|
||||
id: "people",
|
||||
href: "/ase/people",
|
||||
href: "/admin-next/people",
|
||||
iconId: "users",
|
||||
label: "pages.housekeeping.domains.people.title",
|
||||
description: "pages.housekeeping.domains.people.description",
|
||||
@@ -69,19 +69,19 @@ describe("housekeeping navigation", () => {
|
||||
labelKey: "people.title",
|
||||
descriptionKey: "people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
previewHref: "/admin-next/people",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
routes: [
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "people.users",
|
||||
href: "/ase/people/users",
|
||||
href: "/admin-next/people/users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
{
|
||||
id: "bans",
|
||||
labelKey: "people.bans",
|
||||
href: "/ase/people/bans",
|
||||
href: "/admin-next/people/bans",
|
||||
capability: anyCapability(PERMS.BANS_VIEW),
|
||||
},
|
||||
],
|
||||
@@ -94,7 +94,7 @@ describe("housekeeping navigation", () => {
|
||||
labelKey: "system.title",
|
||||
descriptionKey: "system.description",
|
||||
iconId: "settings",
|
||||
canonicalHref: "/ase/system",
|
||||
previewHref: "/admin-next/system",
|
||||
capability: anyCapability(PERMS.SETTINGS_VIEW),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
@@ -113,14 +113,14 @@ describe("housekeeping navigation", () => {
|
||||
expect(navigation).toEqual([
|
||||
{
|
||||
id: "people",
|
||||
href: "/ase/people",
|
||||
href: "/admin-next/people",
|
||||
iconId: "users",
|
||||
label: "translated:people.title",
|
||||
description: "translated:people.description",
|
||||
items: [
|
||||
{
|
||||
id: "users",
|
||||
href: "/ase/people/users",
|
||||
href: "/admin-next/people/users",
|
||||
label: "translated:people.users",
|
||||
},
|
||||
],
|
||||
@@ -167,37 +167,3 @@ describe("housekeeping navigation", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps navigation hrefs on the canonical surface", () => {
|
||||
const registry = createHousekeepingRegistry([
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "people.title",
|
||||
descriptionKey: "people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
routes: [
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "people.users",
|
||||
href: "/ase/people/users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
},
|
||||
]);
|
||||
const navigation = buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([PERMS.USERS_VIEW]),
|
||||
(key) => key,
|
||||
);
|
||||
|
||||
expect(navigation[0]).toMatchObject({
|
||||
href: "/ase/people",
|
||||
items: [{ href: "/ase/people/users" }],
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
HousekeepingDomainManifest,
|
||||
} from "./contracts";
|
||||
import type { HousekeepingRegistry } from "./registry";
|
||||
import { toHousekeepingHref } from "./routing/href";
|
||||
|
||||
export interface HousekeepingNavigationDomain {
|
||||
id: HousekeepingDomainId;
|
||||
@@ -25,7 +24,7 @@ export function buildHousekeepingNavigation(
|
||||
.filter((domain) => satisfiesCapability(context, domain.capability))
|
||||
.map((domain) => ({
|
||||
id: domain.id,
|
||||
href: toHousekeepingHref(domain.canonicalHref),
|
||||
href: domain.previewHref,
|
||||
iconId: domain.iconId,
|
||||
label: translate(domain.labelKey),
|
||||
description: translate(domain.descriptionKey),
|
||||
@@ -33,7 +32,7 @@ export function buildHousekeepingNavigation(
|
||||
.filter((route) => satisfiesCapability(context, route.capability))
|
||||
.map((route) => ({
|
||||
id: route.id,
|
||||
href: toHousekeepingHref(route.href),
|
||||
href: route.href,
|
||||
label: translate(route.labelKey),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -16,16 +16,10 @@ export function HousekeepingPageShell({
|
||||
children,
|
||||
}: HousekeepingPageShellProps) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="housekeeping-page-title"
|
||||
className="min-w-0 max-w-full space-y-6"
|
||||
>
|
||||
<section className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1
|
||||
id="housekeeping-page-title"
|
||||
className="text-xl font-semibold text-[var(--admin-text)]"
|
||||
>
|
||||
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
@@ -35,7 +29,7 @@ export function HousekeepingPageShell({
|
||||
</div>
|
||||
{primaryAction ? <div>{primaryAction}</div> : null}
|
||||
</header>
|
||||
<div className="min-w-0 max-w-full">{children}</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ describe("HousekeepingPageState", () => {
|
||||
["empty", "status"],
|
||||
["partial", "status"],
|
||||
["error", "alert"],
|
||||
["forbidden", "alert"],
|
||||
["ready", "status"],
|
||||
] as const)("renders %s with the %s role", (state, role) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
@@ -36,35 +34,6 @@ describe("HousekeepingPageState", () => {
|
||||
expect(html).toContain(`>${state} description<`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", "polite"],
|
||||
["empty", "polite"],
|
||||
["partial", "polite"],
|
||||
["error", "assertive"],
|
||||
["forbidden", "assertive"],
|
||||
["ready", "polite"],
|
||||
] as const)("announces %s changes with %s priority", (state, live) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial"
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
) : (
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
);
|
||||
const html = renderToStaticMarkup(pageState);
|
||||
|
||||
expect(html).toContain(`data-page-state="${state}"`);
|
||||
expect(html).toContain(`aria-live="${live}"`);
|
||||
});
|
||||
|
||||
it("announces loading politely", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
@@ -106,12 +75,12 @@ describe("HousekeepingPageState", () => {
|
||||
state="error"
|
||||
title="Could not load"
|
||||
description="Try again later"
|
||||
retryAction={<a href="/ase/operations">Retry preview</a>}
|
||||
retryAction={<a href="/admin-next/operations">Retry preview</a>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/ase/operations"');
|
||||
expect(html).toContain('href="/admin-next/operations"');
|
||||
expect(html).toContain(">Retry preview<");
|
||||
});
|
||||
});
|
||||
@@ -123,7 +92,7 @@ describe("HousekeepingPageShell", () => {
|
||||
title="People"
|
||||
description="Review operator-facing people data"
|
||||
context={<span>Preview context</span>}
|
||||
primaryAction={<a href="/ase/people/new">Create preview</a>}
|
||||
primaryAction={<a href="/admin-next/people/new">Create preview</a>}
|
||||
>
|
||||
<p>Page body</p>
|
||||
</HousekeepingPageShell>,
|
||||
@@ -133,7 +102,7 @@ describe("HousekeepingPageShell", () => {
|
||||
expect(html).toContain(">People<");
|
||||
expect(html).toContain(">Review operator-facing people data<");
|
||||
expect(html).toContain(">Preview context<");
|
||||
expect(html).toContain('href="/ase/people/new"');
|
||||
expect(html).toContain('href="/admin-next/people/new"');
|
||||
expect(html).toContain(">Create preview<");
|
||||
expect(html).toContain(">Page body</p>");
|
||||
});
|
||||
|
||||
@@ -6,21 +6,10 @@ interface HousekeepingPageStateBaseProps {
|
||||
retryAction?: ReactNode;
|
||||
}
|
||||
|
||||
export type HousekeepingPageState =
|
||||
| "loading"
|
||||
| "empty"
|
||||
| "partial"
|
||||
| "error"
|
||||
| "forbidden"
|
||||
| "ready";
|
||||
|
||||
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
|
||||
(
|
||||
| { state: "partial"; partialLabel: string }
|
||||
| {
|
||||
state: Exclude<HousekeepingPageState, "partial">;
|
||||
partialLabel?: never;
|
||||
}
|
||||
| { state: "loading" | "empty" | "error"; partialLabel?: never }
|
||||
);
|
||||
|
||||
export function HousekeepingPageState({
|
||||
@@ -30,14 +19,13 @@ export function HousekeepingPageState({
|
||||
description,
|
||||
retryAction,
|
||||
}: HousekeepingPageStateProps) {
|
||||
const isError = state === "error" || state === "forbidden";
|
||||
const isError = state === "error";
|
||||
const isPartial = state === "partial";
|
||||
|
||||
return (
|
||||
<section
|
||||
role={isError ? "alert" : "status"}
|
||||
aria-live={isError ? "assertive" : "polite"}
|
||||
data-page-state={state}
|
||||
aria-live={state === "loading" ? "polite" : undefined}
|
||||
className={`rounded-lg border p-4 ${
|
||||
isError
|
||||
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "../../../..");
|
||||
const migrationPath = resolve(
|
||||
projectRoot,
|
||||
"drizzle/migrations/0023_housekeeping.sql",
|
||||
);
|
||||
const schemaPath = resolve(projectRoot, "src/db/schema.ts");
|
||||
const legacyAuditColumns = [
|
||||
"action",
|
||||
"target",
|
||||
"target_id",
|
||||
"details",
|
||||
"before",
|
||||
"after",
|
||||
"diff",
|
||||
"ip_address",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] as const;
|
||||
|
||||
function migrationSource(): string {
|
||||
return readFileSync(migrationPath, "utf8");
|
||||
}
|
||||
|
||||
function schemaSource(): string {
|
||||
return readFileSync(schemaPath, "utf8");
|
||||
}
|
||||
|
||||
describe("housekeeping persistence contract", () => {
|
||||
it("adds only the approved nullable audit columns and correlation index", () => {
|
||||
const migration = migrationSource();
|
||||
const auditAlter = migration.match(
|
||||
/ALTER TABLE `admin_audit_log`[\s\S]*?;/,
|
||||
)?.[0];
|
||||
|
||||
expect(auditAlter).toBeDefined();
|
||||
expect(auditAlter).toContain(
|
||||
"ADD COLUMN `correlation_id` VARCHAR(64) NULL",
|
||||
);
|
||||
expect(auditAlter).toContain("ADD COLUMN `outcome` VARCHAR(32) NULL");
|
||||
expect(auditAlter).toContain("ADD COLUMN `reason` TEXT NULL");
|
||||
expect(auditAlter).toContain("ADD COLUMN `domain` VARCHAR(32) NULL");
|
||||
expect(auditAlter).toContain(
|
||||
"ADD INDEX `admin_audit_log_correlation_id_idx` (`correlation_id`)",
|
||||
);
|
||||
expect(auditAlter).not.toMatch(/\b(?:DROP|RENAME|MODIFY|CHANGE)\b/i);
|
||||
for (const column of legacyAuditColumns) {
|
||||
expect(auditAlter).not.toMatch(
|
||||
new RegExp(
|
||||
`\\b(?:ADD|DROP|RENAME|MODIFY|CHANGE)\\s+COLUMN\\s+\\\`${column}\\\``,
|
||||
"i",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the exact user-scoped preference table with one primary key", () => {
|
||||
const migration = migrationSource();
|
||||
const preferenceTable = migration.match(
|
||||
/CREATE TABLE `housekeeping_user_preferences` \([\s\S]*?\);/,
|
||||
)?.[0];
|
||||
|
||||
expect(preferenceTable).toBeDefined();
|
||||
expect(preferenceTable).toContain("`user_id` INT NOT NULL");
|
||||
expect(preferenceTable).toContain(
|
||||
"`schema_version` INT NOT NULL DEFAULT 1",
|
||||
);
|
||||
expect(preferenceTable).toContain("`payload` LONGTEXT NOT NULL");
|
||||
expect(preferenceTable).toContain(
|
||||
"`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
expect(preferenceTable).toContain(
|
||||
"`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
);
|
||||
expect(preferenceTable?.match(/PRIMARY KEY/g)).toHaveLength(1);
|
||||
expect(preferenceTable).toContain("PRIMARY KEY (`user_id`)");
|
||||
});
|
||||
|
||||
it("declares matching additive Drizzle tables and the published preference row type", () => {
|
||||
const schema = schemaSource();
|
||||
|
||||
expect(schema).toContain("export const AdminAuditLog = mysqlTable(");
|
||||
expect(schema).toContain('"admin_audit_log"');
|
||||
expect(schema).toContain(
|
||||
'correlationId: varchar("correlation_id", { length: 64 })',
|
||||
);
|
||||
expect(schema).toContain('outcome: varchar("outcome", { length: 32 })');
|
||||
expect(schema).toContain('reason: text("reason")');
|
||||
expect(schema).toContain('domain: varchar("domain", { length: 32 })');
|
||||
expect(schema).toContain(
|
||||
'index("admin_audit_log_correlation_id_idx").on(t.correlationId)',
|
||||
);
|
||||
expect(schema).toContain(
|
||||
"export const HousekeepingUserPreferences = mysqlTable(",
|
||||
);
|
||||
expect(schema).toContain('"housekeeping_user_preferences"');
|
||||
expect(schema).toContain('userId: int("user_id").primaryKey().notNull()');
|
||||
expect(schema).toContain(
|
||||
'schemaVersion: int("schema_version").notNull().default(1)',
|
||||
);
|
||||
expect(schema).toContain('payload: longtext("payload").notNull()');
|
||||
expect(schema).toContain(
|
||||
'createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`)',
|
||||
);
|
||||
expect(schema).toContain(
|
||||
'updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`)',
|
||||
);
|
||||
expect(schema).toContain("export type HousekeepingUserPreferenceRow =");
|
||||
expect(schema).toContain(
|
||||
"typeof HousekeepingUserPreferences.$inferSelect;",
|
||||
);
|
||||
});
|
||||
|
||||
it("contains no destructive DDL", () => {
|
||||
const migration = migrationSource();
|
||||
|
||||
expect(migration).not.toMatch(/\b(?:DROP|RENAME)\b/i);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import { reconcilePreferences } from "./reconcile";
|
||||
import { defaultHousekeepingPreferences } from "./schema";
|
||||
|
||||
const usersView = anyCapability("admin.users.view");
|
||||
const ticketsView = anyCapability("admin.tickets.view");
|
||||
const bansView = anyCapability("admin.bans.view");
|
||||
|
||||
const registry: HousekeepingRegistry = {
|
||||
domains: [
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "people",
|
||||
descriptionKey: "people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: usersView,
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
labelKey: "users",
|
||||
href: "/ase/people/users",
|
||||
capability: usersView,
|
||||
},
|
||||
{
|
||||
id: "people.tickets",
|
||||
labelKey: "tickets",
|
||||
href: "/ase/people/tickets",
|
||||
capability: ticketsView,
|
||||
},
|
||||
{
|
||||
id: "people.bans",
|
||||
labelKey: "bans",
|
||||
href: "/ase/people/bans",
|
||||
capability: bansView,
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [
|
||||
{
|
||||
id: "people.summary",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "mandatory",
|
||||
load: async () => ({ ok: true, data: null, correlationId: "widget" }),
|
||||
},
|
||||
{
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability: ticketsView,
|
||||
kind: "optional",
|
||||
load: async () => ({ ok: true, data: null, correlationId: "widget" }),
|
||||
},
|
||||
{
|
||||
id: "people.bans",
|
||||
owner: "people",
|
||||
capability: bansView,
|
||||
kind: "optional",
|
||||
load: async () => ({ ok: true, data: null, correlationId: "widget" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 0 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === "admin.users.view" || slug === "admin.tickets.view",
|
||||
hasAny: (...slugs) => slugs.some(context.has),
|
||||
hasAll: (...slugs) => slugs.every(context.has),
|
||||
};
|
||||
|
||||
describe("reconcilePreferences", () => {
|
||||
it("drops unknown and unauthorized IDs before returning preferences", () => {
|
||||
const stored = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["removed.route", "people.bans", "people.users"],
|
||||
pinnedCommandIds: ["removed.command"],
|
||||
shortcutOrder: [
|
||||
"removed.route",
|
||||
"people.bans",
|
||||
"people.tickets",
|
||||
"people.users",
|
||||
],
|
||||
widgetOrder: ["removed.widget", "people.bans", "people.queue"],
|
||||
enabledOptionalWidgetIds: [
|
||||
"removed.widget",
|
||||
"people.bans",
|
||||
"people.queue",
|
||||
],
|
||||
};
|
||||
|
||||
expect(reconcilePreferences(stored, registry, context)).toEqual({
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.users"],
|
||||
shortcutOrder: ["people.tickets", "people.users"],
|
||||
widgetOrder: ["people.queue", "people.summary"],
|
||||
enabledOptionalWidgetIds: ["people.queue"],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains mandatory widgets and preserves the relative order of valid entries", () => {
|
||||
const stored = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.tickets", "people.users"],
|
||||
shortcutOrder: ["people.tickets", "people.users"],
|
||||
widgetOrder: ["people.queue"],
|
||||
};
|
||||
|
||||
expect(reconcilePreferences(stored, registry, context)).toMatchObject({
|
||||
pinnedRouteIds: ["people.tickets", "people.users"],
|
||||
shortcutOrder: ["people.tickets", "people.users"],
|
||||
widgetOrder: ["people.queue", "people.summary"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingWidgetDefinition,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import type { HousekeepingPreferences } from "./schema";
|
||||
|
||||
export function reconcilePreferences(
|
||||
stored: HousekeepingPreferences,
|
||||
registry: HousekeepingRegistry,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): HousekeepingPreferences {
|
||||
const routes = registry.domains
|
||||
.flatMap((domain) => domain.routes)
|
||||
.filter((route) => satisfiesCapability(context, route.capability));
|
||||
const widgets = registry.domains
|
||||
.flatMap((domain) => domain.widgets)
|
||||
.filter((widget) => satisfiesCapability(context, widget.capability));
|
||||
const allowedRouteIds = new Set(routes.map((route) => route.id));
|
||||
const allowedWidgetIds = new Set(widgets.map((widget) => widget.id));
|
||||
const allowedOptionalWidgetIds = new Set(
|
||||
widgets
|
||||
.filter((widget) => widget.kind === "optional")
|
||||
.map((widget) => widget.id),
|
||||
);
|
||||
const mandatoryWidgets = widgets.filter(
|
||||
(widget): widget is HousekeepingWidgetDefinition & { kind: "mandatory" } =>
|
||||
widget.kind === "mandatory",
|
||||
);
|
||||
const retainedWidgetOrder = filterKnown(stored.widgetOrder, allowedWidgetIds);
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pinnedRouteIds: filterKnown(stored.pinnedRouteIds, allowedRouteIds),
|
||||
// Commands are intentionally omitted until the command registry publishes
|
||||
// a stable registry collection; unregistered IDs are never trusted.
|
||||
pinnedCommandIds: [],
|
||||
shortcutOrder: filterKnown(stored.shortcutOrder, allowedRouteIds),
|
||||
widgetOrder: appendMissing(
|
||||
retainedWidgetOrder,
|
||||
mandatoryWidgets.map((widget) => widget.id),
|
||||
),
|
||||
enabledOptionalWidgetIds: filterKnown(
|
||||
stored.enabledOptionalWidgetIds,
|
||||
allowedOptionalWidgetIds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function filterKnown(
|
||||
values: readonly string[],
|
||||
allowed: ReadonlySet<string>,
|
||||
): string[] {
|
||||
return values.filter((value) => allowed.has(value));
|
||||
}
|
||||
|
||||
function appendMissing(
|
||||
values: readonly string[],
|
||||
required: readonly string[],
|
||||
): string[] {
|
||||
const combined = [...values];
|
||||
const known = new Set(combined);
|
||||
for (const value of required) {
|
||||
if (!known.has(value)) combined.push(value);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const drizzleMocks = vi.hoisted(() => ({
|
||||
eq: vi.fn((column: unknown, value: unknown) => ({ column, value })),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("drizzle-orm")>()),
|
||||
eq: drizzleMocks.eq,
|
||||
}));
|
||||
|
||||
import { HousekeepingUserPreferences } from "@/lib/db";
|
||||
import { createDrizzleHousekeepingPreferencesStorage } from "@/lib/housekeeping-preferences-repository";
|
||||
import {
|
||||
createHousekeepingPreferencesRepository,
|
||||
type HousekeepingPreferencesStorage,
|
||||
} from "./repository";
|
||||
import { defaultHousekeepingPreferences } from "./schema";
|
||||
|
||||
describe("housekeeping preferences repository", () => {
|
||||
it("returns a fresh default when no row exists", async () => {
|
||||
const storage: HousekeepingPreferencesStorage = {
|
||||
findByUserId: vi.fn().mockResolvedValue(null),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
const repository = createHousekeepingPreferencesRepository(storage);
|
||||
|
||||
expect(await repository.read(42)).toEqual(defaultHousekeepingPreferences());
|
||||
expect(storage.findByUserId).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("reads validated JSON and writes the schema-versioned payload through the injected adapter", async () => {
|
||||
const value = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.users"],
|
||||
};
|
||||
const storage: HousekeepingPreferencesStorage = {
|
||||
findByUserId: vi.fn().mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
}),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
const repository = createHousekeepingPreferencesRepository(storage);
|
||||
|
||||
expect(await repository.read(42)).toEqual(value);
|
||||
await repository.upsert(42, value);
|
||||
expect(storage.upsert).toHaveBeenCalledWith({
|
||||
userId: 42,
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a fresh default for malformed or incompatible stored values", async () => {
|
||||
const storage: HousekeepingPreferencesStorage = {
|
||||
findByUserId: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ schemaVersion: 1, payload: "{broken" })
|
||||
.mockResolvedValueOnce({ schemaVersion: 2, payload: "{}" })
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify({ schemaVersion: 1 }),
|
||||
}),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
const repository = createHousekeepingPreferencesRepository(storage);
|
||||
|
||||
await expect(repository.read(42)).resolves.toEqual(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
await expect(repository.read(42)).resolves.toEqual(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
await expect(repository.read(42)).resolves.toEqual(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves genuine storage failures instead of treating them as corrupt preferences", async () => {
|
||||
const storage: HousekeepingPreferencesStorage = {
|
||||
findByUserId: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("database unavailable")),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
createHousekeepingPreferencesRepository(storage).read(42),
|
||||
).rejects.toThrow("database unavailable");
|
||||
});
|
||||
|
||||
it("uses the preferences table and user ID for the typed Drizzle read and upsert", async () => {
|
||||
const limit = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onDuplicateKeyUpdate });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
const storage = createDrizzleHousekeepingPreferencesStorage({
|
||||
select,
|
||||
insert,
|
||||
} as never);
|
||||
const value = defaultHousekeepingPreferences();
|
||||
|
||||
await storage.findByUserId(42);
|
||||
await storage.upsert({
|
||||
userId: 42,
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
|
||||
expect(select).toHaveBeenCalledWith({
|
||||
schemaVersion: HousekeepingUserPreferences.schemaVersion,
|
||||
payload: HousekeepingUserPreferences.payload,
|
||||
});
|
||||
expect(from).toHaveBeenCalledWith(HousekeepingUserPreferences);
|
||||
expect(drizzleMocks.eq).toHaveBeenCalledWith(
|
||||
HousekeepingUserPreferences.userId,
|
||||
42,
|
||||
);
|
||||
expect(where).toHaveBeenCalledWith({
|
||||
column: HousekeepingUserPreferences.userId,
|
||||
value: 42,
|
||||
});
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
userId: 42,
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
expect(onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||
set: expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { HousekeepingPreferences } from "./schema";
|
||||
import {
|
||||
defaultHousekeepingPreferences,
|
||||
parseHousekeepingPreferences,
|
||||
} from "./schema";
|
||||
|
||||
export interface HousekeepingPreferencesStorageRow {
|
||||
schemaVersion: number;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingPreferencesStorage {
|
||||
findByUserId(
|
||||
userId: number,
|
||||
): Promise<HousekeepingPreferencesStorageRow | null>;
|
||||
upsert(row: {
|
||||
userId: number;
|
||||
schemaVersion: 1;
|
||||
payload: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HousekeepingPreferencesRepository {
|
||||
read(userId: number): Promise<HousekeepingPreferences>;
|
||||
upsert(userId: number, value: HousekeepingPreferences): Promise<void>;
|
||||
}
|
||||
|
||||
export function createHousekeepingPreferencesRepository(
|
||||
storage: HousekeepingPreferencesStorage,
|
||||
): HousekeepingPreferencesRepository {
|
||||
return {
|
||||
async read(userId) {
|
||||
const row = await storage.findByUserId(userId);
|
||||
if (!row) return defaultHousekeepingPreferences();
|
||||
|
||||
try {
|
||||
if (row.schemaVersion !== 1) {
|
||||
return defaultHousekeepingPreferences();
|
||||
}
|
||||
return parseHousekeepingPreferences(row.payload);
|
||||
} catch {
|
||||
return defaultHousekeepingPreferences();
|
||||
}
|
||||
},
|
||||
async upsert(userId, value) {
|
||||
await storage.upsert({
|
||||
userId,
|
||||
schemaVersion: value.schemaVersion,
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultHousekeepingPreferences,
|
||||
housekeepingPreferencesSchema,
|
||||
parseHousekeepingPreferences,
|
||||
} from "./schema";
|
||||
|
||||
describe("housekeeping preferences schema", () => {
|
||||
it("rejects malformed serialized JSON", () => {
|
||||
expect(() => parseHousekeepingPreferences("{not-json")).toThrow(
|
||||
"invalid housekeeping preferences JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown keys, duplicate identifiers, and unsupported versions", () => {
|
||||
const valid = defaultHousekeepingPreferences();
|
||||
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({ ...valid, unsupported: true })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...valid,
|
||||
pinnedRouteIds: ["people.users", "people.users"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({ ...valid, schemaVersion: 2 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses the current validated presentation payload", () => {
|
||||
const value = {
|
||||
schemaVersion: 1,
|
||||
pinnedRouteIds: ["people.users"],
|
||||
pinnedCommandIds: [],
|
||||
shortcutOrder: ["people.users"],
|
||||
widgetOrder: ["people.summary"],
|
||||
enabledOptionalWidgetIds: [],
|
||||
};
|
||||
|
||||
expect(parseHousekeepingPreferences(JSON.stringify(value))).toEqual(value);
|
||||
});
|
||||
|
||||
it("rejects identifiers and lists that exceed the bounded presentation payload", () => {
|
||||
const value = defaultHousekeepingPreferences();
|
||||
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: ["a".repeat(129)],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: Array.from(
|
||||
{ length: 65 },
|
||||
(_, index) => `route.${index}`,
|
||||
),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: Array.from({ length: 33 }, (_, index) =>
|
||||
`${index}`.padEnd(128, "a"),
|
||||
),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a Unicode payload whose UTF-8 bytes exceed the 4096-byte limit", () => {
|
||||
const value = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: Array.from(
|
||||
{ length: 29 },
|
||||
(_, index) => `${index.toString().padStart(3, "0")}${"é".repeat(125)}`,
|
||||
),
|
||||
};
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
expect(serialized.length).toBeLessThanOrEqual(4096);
|
||||
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(
|
||||
4096,
|
||||
);
|
||||
expect(housekeepingPreferencesSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH = 128;
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY = 64;
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES = 4096;
|
||||
|
||||
export interface HousekeepingPreferences {
|
||||
schemaVersion: 1;
|
||||
pinnedRouteIds: string[];
|
||||
pinnedCommandIds: string[];
|
||||
shortcutOrder: string[];
|
||||
widgetOrder: string[];
|
||||
enabledOptionalWidgetIds: string[];
|
||||
}
|
||||
|
||||
const uniqueIdentifiers = z
|
||||
.array(
|
||||
z.string().trim().min(1).max(HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH),
|
||||
)
|
||||
.max(HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY)
|
||||
.superRefine((values, context) => {
|
||||
const seen = new Set<string>();
|
||||
for (const [index, value] of values.entries()) {
|
||||
if (seen.has(value)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "duplicate preference identifier",
|
||||
path: [index],
|
||||
});
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
});
|
||||
|
||||
export const housekeepingPreferencesSchema: z.ZodType<HousekeepingPreferences> =
|
||||
z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
pinnedRouteIds: uniqueIdentifiers,
|
||||
pinnedCommandIds: uniqueIdentifiers,
|
||||
shortcutOrder: uniqueIdentifiers,
|
||||
widgetOrder: uniqueIdentifiers,
|
||||
enabledOptionalWidgetIds: uniqueIdentifiers,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((preferences, context) => {
|
||||
if (
|
||||
new TextEncoder().encode(JSON.stringify(preferences)).byteLength >
|
||||
HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "housekeeping preferences payload is too large",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function defaultHousekeepingPreferences(): HousekeepingPreferences {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pinnedRouteIds: [],
|
||||
pinnedCommandIds: [],
|
||||
shortcutOrder: [],
|
||||
widgetOrder: [],
|
||||
enabledOptionalWidgetIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHousekeepingPreferences(
|
||||
serialized: string,
|
||||
): HousekeepingPreferences {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(serialized);
|
||||
} catch {
|
||||
throw new Error("invalid housekeeping preferences JSON");
|
||||
}
|
||||
|
||||
return housekeepingPreferencesSchema.parse(value);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { toHousekeepingWidgetOptions } from "./widget-options";
|
||||
|
||||
describe("toHousekeepingWidgetOptions", () => {
|
||||
it("projects provider definitions to a serializable client view model", () => {
|
||||
const load = vi.fn(async () => ok({ count: 1 }, "widget"));
|
||||
const definitions: readonly HousekeepingWidgetDefinition[] = [
|
||||
{
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability: anyCapability("admin.users.view"),
|
||||
kind: "mandatory",
|
||||
load,
|
||||
},
|
||||
];
|
||||
|
||||
const options = toHousekeepingWidgetOptions(definitions);
|
||||
|
||||
expect(options).toEqual([{ id: "people.queue", kind: "mandatory" }]);
|
||||
expect(structuredClone(options)).toEqual(options);
|
||||
expect(options[0]).not.toHaveProperty("load");
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import type {
|
||||
HousekeepingWidgetDefinition,
|
||||
HousekeepingWidgetKind,
|
||||
} from "../contracts";
|
||||
|
||||
export interface HousekeepingWidgetOption {
|
||||
readonly id: string;
|
||||
readonly kind: HousekeepingWidgetKind;
|
||||
}
|
||||
|
||||
export function toHousekeepingWidgetOptions(
|
||||
definitions: readonly HousekeepingWidgetDefinition[],
|
||||
): readonly HousekeepingWidgetOption[] {
|
||||
return definitions.map(({ id, kind }) => ({ id, kind }));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||
|
||||
describe("isHousekeepingPreviewEnabled", () => {
|
||||
it.each([
|
||||
["development", true, true],
|
||||
["test", true, true],
|
||||
["development", false, false],
|
||||
["production", true, false],
|
||||
["production", false, false],
|
||||
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
|
||||
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
|
||||
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
|
||||
const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSkipValidation === undefined) {
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
} else {
|
||||
process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
|
||||
}
|
||||
|
||||
if (originalPreviewFlag === undefined) {
|
||||
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||
} else {
|
||||
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["true", true],
|
||||
["1", true],
|
||||
["false", false],
|
||||
["yes", false],
|
||||
[undefined, false],
|
||||
] as const)("normalizes %s to %s", async (value, expected) => {
|
||||
if (value === undefined) {
|
||||
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||
} else {
|
||||
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value;
|
||||
}
|
||||
|
||||
const { env } = await import("@/env");
|
||||
|
||||
expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function isHousekeepingPreviewEnabled(input: {
|
||||
nodeEnv: "development" | "test" | "production";
|
||||
flag: boolean;
|
||||
}): boolean {
|
||||
return input.nodeEnv !== "production" && input.flag;
|
||||
}
|
||||
@@ -9,146 +9,16 @@ import type { HousekeepingCapabilityContext } from "./contracts";
|
||||
|
||||
const routeMocks = vi.hoisted(() => {
|
||||
const messages: Record<string, string> = {
|
||||
"preview.badge": "HK::preview-badge",
|
||||
"preview.commandDisabled": "HK::command-disabled",
|
||||
"preview.backToSite": "HK::back-to-site",
|
||||
"navigation.skipToContent": "HK::skip-to-content",
|
||||
"navigation.primary": "HK::primary-navigation",
|
||||
"navigation.contextual": "HK::contextual-navigation",
|
||||
"navigation.operatorRank": "HK::operator-rank",
|
||||
"commandDeck.placeholder": "HK::command-placeholder",
|
||||
"commandDeck.navigation": "HK::command-navigation",
|
||||
"commandDeck.commands": "HK::command-commands",
|
||||
"commandDeck.entities": "HK::command-entities",
|
||||
"commandDeck.loading": "HK::command-loading",
|
||||
"commandDeck.empty": "HK::command-empty",
|
||||
"commandDeck.partial": "HK::command-partial",
|
||||
"commandDeck.close": "HK::command-close",
|
||||
"domains.people.title": "HK::people-title",
|
||||
"domains.people.description": "Localized People description",
|
||||
"domains.economy.title": "Localized Economy",
|
||||
"domains.economy.description": "Localized Economy description",
|
||||
"domains.operations.title": "Localized Operations",
|
||||
"domains.operations.description": "Localized Operations description",
|
||||
"domains.content.title": "Localized Content",
|
||||
"domains.content.description": "Localized Content description",
|
||||
"domains.hotel.title": "Localized Hotel",
|
||||
"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",
|
||||
"routes.system.configuration.settings": "HK::system-configuration-settings",
|
||||
"routes.system.configuration.emulator": "HK::system-configuration-emulator",
|
||||
"routes.system.observability.analytics":
|
||||
"HK::system-observability-analytics",
|
||||
"routes.system.observability.analytics-activity":
|
||||
"HK::system-observability-analytics-activity",
|
||||
"routes.system.observability.analytics-economy":
|
||||
"HK::system-observability-analytics-economy",
|
||||
"routes.system.observability.devops": "HK::system-observability-devops",
|
||||
"routes.system.observability.devops-errors":
|
||||
"HK::system-observability-devops-errors",
|
||||
"routes.system.observability.logs-staff":
|
||||
"HK::system-observability-logs-staff",
|
||||
"routes.system.observability.logs-audit":
|
||||
"HK::system-observability-logs-audit",
|
||||
"routes.system.observability.logs-chat":
|
||||
"HK::system-observability-logs-chat",
|
||||
"routes.system.observability.logs-commands":
|
||||
"HK::system-observability-logs-commands",
|
||||
"routes.system.observability.logs-trades":
|
||||
"HK::system-observability-logs-trades",
|
||||
"routes.system.operations.alerts": "HK::system-operations-alerts",
|
||||
"routes.system.operations.command-center":
|
||||
"HK::system-operations-command-center",
|
||||
"routes.system.operations.maintenance": "HK::system-operations-maintenance",
|
||||
"routes.economy.catalog.overview": "HK::economy-catalog-overview",
|
||||
"routes.economy.catalog.detail": "HK::economy-catalog-detail",
|
||||
"routes.economy.catalog.builder-club-detail":
|
||||
"HK::economy-catalog-builder-club-detail",
|
||||
"routes.economy.catalog.maintenance": "HK::economy-catalog-maintenance",
|
||||
"routes.economy.items.overview": "HK::economy-items-overview",
|
||||
"routes.economy.items.detail": "HK::economy-items-detail",
|
||||
"routes.economy.commerce.shop": "HK::economy-commerce-shop",
|
||||
"routes.economy.commerce.shop-create": "HK::economy-commerce-shop-create",
|
||||
"routes.economy.commerce.shop-detail": "HK::economy-commerce-shop-detail",
|
||||
"routes.economy.commerce.marketplace": "HK::economy-commerce-marketplace",
|
||||
"routes.economy.commerce.vouchers": "HK::economy-commerce-vouchers",
|
||||
"routes.economy.commerce.subscriptions":
|
||||
"HK::economy-commerce-subscriptions",
|
||||
"routes.economy.history.transactions": "HK::economy-history-transactions",
|
||||
"routes.economy.value.rare-values": "HK::economy-value-rare-values",
|
||||
"routes.economy.rewards.badges": "HK::economy-rewards-badges",
|
||||
"routes.economy.rewards.achievements": "HK::economy-rewards-achievements",
|
||||
"routes.economy.rewards.sounds": "HK::economy-rewards-sounds",
|
||||
"routes.economy.rewards.calendar": "HK::economy-rewards-calendar",
|
||||
"routes.economy.rewards.calendar-detail":
|
||||
"HK::economy-rewards-calendar-detail",
|
||||
"routes.hotel.rooms": "HK::hotel-rooms",
|
||||
"routes.hotel.room-detail": "HK::hotel-room-detail",
|
||||
"routes.hotel.room-furni": "HK::hotel-room-furni",
|
||||
"routes.hotel.navigation": "HK::hotel-navigation",
|
||||
"routes.hotel.radio.overview": "HK::hotel-radio-overview",
|
||||
"routes.hotel.radio.settings": "HK::hotel-radio-settings",
|
||||
"routes.hotel.radio.monitoring": "HK::hotel-radio-monitoring",
|
||||
"routes.hotel.radio.moderation": "HK::hotel-radio-moderation",
|
||||
"routes.hotel.radio.autodj": "HK::hotel-radio-autodj",
|
||||
"routes.hotel.radio.history": "HK::hotel-radio-history",
|
||||
"routes.hotel.radio.points": "HK::hotel-radio-points",
|
||||
"routes.hotel.radio.ranks": "HK::hotel-radio-ranks",
|
||||
"routes.hotel.radio.api-keys": "HK::hotel-radio-api-keys",
|
||||
"routes.hotel.radio.banners": "HK::hotel-radio-banners",
|
||||
"routes.hotel.radio.embed": "HK::hotel-radio-embed",
|
||||
"routes.hotel.studio.audit": "HK::hotel-studio-audit",
|
||||
"routes.hotel.studio.badges": "HK::hotel-studio-badges",
|
||||
"routes.hotel.studio.clone": "HK::hotel-studio-clone",
|
||||
"routes.hotel.studio.clothing": "HK::hotel-studio-clothing",
|
||||
"routes.hotel.studio.effects": "HK::hotel-studio-effects",
|
||||
"routes.hotel.studio.furni": "HK::hotel-studio-furni",
|
||||
"routes.hotel.studio.maintenance": "HK::hotel-studio-maintenance",
|
||||
"routes.hotel.studio.pets": "HK::hotel-studio-pets",
|
||||
"routes.hotel.studio.repair-icons": "HK::hotel-studio-repair-icons",
|
||||
"routes.hotel.studio.sync": "HK::hotel-studio-sync",
|
||||
"routes.hotel.studio.upload": "HK::hotel-studio-upload",
|
||||
"routes.operations.workspace": "HK::operations-workspace",
|
||||
"states.empty.title": "Localized empty title",
|
||||
"states.empty.description": "Localized empty description",
|
||||
};
|
||||
@@ -160,6 +30,10 @@ const routeMocks = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
return {
|
||||
env: {
|
||||
NODE_ENV: "test" as "development" | "test" | "production",
|
||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
|
||||
},
|
||||
getHousekeepingCapabilityContext: vi.fn(),
|
||||
getTranslations: vi.fn(async (namespace: string) => {
|
||||
if (namespace !== "pages.housekeeping") {
|
||||
@@ -173,17 +47,11 @@ const routeMocks = vi.hoisted(() => {
|
||||
redirect: vi.fn((href: string): never => {
|
||||
throw new Error(`NEXT_REDIRECT:${href}`);
|
||||
}),
|
||||
renderHousekeepingRoute: vi.fn(
|
||||
async (input: {
|
||||
context: { actor: { username: string } };
|
||||
match: { routeId: string };
|
||||
}) =>
|
||||
`Rendered ${input.match.routeId} for ${input.context.actor.username}`,
|
||||
),
|
||||
translate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/env", () => ({ env: routeMocks.env }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: routeMocks.notFound,
|
||||
redirect: routeMocks.redirect,
|
||||
@@ -195,114 +63,31 @@ vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
throw new Error("route entrypoints must not import the database");
|
||||
throw new Error("preview routes must not import the database");
|
||||
});
|
||||
vi.mock("@/lib/auth", () => {
|
||||
throw new Error("route entrypoints must not call auth directly");
|
||||
throw new Error("preview routes must not call auth directly");
|
||||
});
|
||||
vi.mock("@/lib/permissions", () => {
|
||||
throw new Error("route entrypoints must not reload permissions directly");
|
||||
throw new Error("preview routes must not reload permissions directly");
|
||||
});
|
||||
vi.mock("@/actions", () => {
|
||||
throw new Error("route entrypoints must not import actions");
|
||||
throw new Error("preview routes must not import actions");
|
||||
});
|
||||
vi.mock("@/app/actions", () => {
|
||||
throw new Error("route entrypoints must not import actions");
|
||||
throw new Error("preview routes must not import actions");
|
||||
});
|
||||
vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/features/housekeeping/manifests")>();
|
||||
|
||||
return {
|
||||
HOUSEKEEPING_MANIFESTS: actual.HOUSEKEEPING_MANIFESTS.map((manifest) =>
|
||||
manifest.id === "people"
|
||||
? {
|
||||
...manifest,
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.users.view", "mod.cfh.view"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "people.user-detail",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users/:id" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.users.view", "mod.cfh.view"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "people.bans",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/bans" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.bans.view"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "people.tool-detail",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/tools/:id" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.users.view"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "people.literal-tool",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/tools/a+b[1]" as const,
|
||||
capability: {
|
||||
mode: "any" as const,
|
||||
slugs: ["admin.users.view"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: manifest,
|
||||
),
|
||||
};
|
||||
});
|
||||
vi.mock("@/features/housekeeping/route-handlers", () => ({
|
||||
HOUSEKEEPING_ROUTE_HANDLERS: [
|
||||
{
|
||||
routeId: "operations.workspace",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
{ routeId: "people.users", render: routeMocks.renderHousekeepingRoute },
|
||||
{
|
||||
routeId: "people.user-detail",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
{ routeId: "people.bans", render: routeMocks.renderHousekeepingRoute },
|
||||
{
|
||||
routeId: "people.tool-detail",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
{
|
||||
routeId: "people.literal-tool",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import HousekeepingDomainPage from "@/app/ase/[domain]/[[...segments]]/page";
|
||||
import HousekeepingDomainLayout from "@/app/ase/[domain]/layout";
|
||||
import HousekeepingLayout from "@/app/ase/layout";
|
||||
import HousekeepingPage from "@/app/ase/page";
|
||||
import AdminNextDomainLayout from "@/app/admin-next/[domain]/layout";
|
||||
import AdminNextDomainPage from "@/app/admin-next/[domain]/page";
|
||||
import AdminNextLayout from "@/app/admin-next/layout";
|
||||
import AdminNextPage from "@/app/admin-next/page";
|
||||
|
||||
const routeFiles = [
|
||||
"src/app/ase/layout.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
"src/app/admin-next/layout.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
"src/app/admin-next/[domain]/layout.tsx",
|
||||
"src/app/admin-next/[domain]/page.tsx",
|
||||
] as const;
|
||||
|
||||
const forbiddenModuleRoots = [
|
||||
@@ -575,42 +360,66 @@ async function renderRoute(route: ReactNode | Promise<ReactNode>) {
|
||||
return renderToStaticMarkup(await route);
|
||||
}
|
||||
|
||||
describe("/ase canonical layout", () => {
|
||||
it("renders without a runtime preview gate", async () => {
|
||||
const html = await renderRoute(
|
||||
HousekeepingLayout({
|
||||
children: createElement("p", null, "Canonical child"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("Canonical child");
|
||||
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||
describe("/admin-next preview gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
routeMocks.env.NODE_ENV = "test";
|
||||
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["production", true],
|
||||
["production", false],
|
||||
["development", false],
|
||||
] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
|
||||
routeMocks.env.NODE_ENV = nodeEnv;
|
||||
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = flag;
|
||||
|
||||
await expect(async () =>
|
||||
renderRoute(
|
||||
AdminNextLayout({
|
||||
children: createElement("p", null, "Preview child"),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["development", "test"] as const)(
|
||||
"renders children in %s when explicitly enabled",
|
||||
async (nodeEnv) => {
|
||||
routeMocks.env.NODE_ENV = nodeEnv;
|
||||
|
||||
const html = await renderRoute(
|
||||
AdminNextLayout({
|
||||
children: createElement("p", null, "Preview child"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("Preview child");
|
||||
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("/ase operational home", () => {
|
||||
describe("/admin-next first visible domain", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the Operations workspace directly for an administrator", async () => {
|
||||
it("redirects an administrator to Operations in locked registry order", async () => {
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
||||
);
|
||||
|
||||
const html = await renderRoute(HousekeepingPage());
|
||||
expect(html).toContain("Rendered operations.workspace");
|
||||
expect(html).toContain("HK::operations-workspace");
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
match: expect.objectContaining({ routeId: "operations.workspace" }),
|
||||
}),
|
||||
await expect(AdminNextPage()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/admin-next/operations",
|
||||
);
|
||||
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||
expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/operations");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects a moderator with only an approved mod view capability to People", async () => {
|
||||
@@ -618,10 +427,10 @@ describe("/ase operational home", () => {
|
||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||
);
|
||||
|
||||
await expect(HousekeepingPage()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/ase/people/users",
|
||||
await expect(AdminNextPage()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/admin-next/people",
|
||||
);
|
||||
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase/people/users");
|
||||
expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
@@ -632,7 +441,7 @@ describe("/ase operational home", () => {
|
||||
capabilityContext([]),
|
||||
);
|
||||
|
||||
await expect(HousekeepingPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
@@ -641,14 +450,14 @@ describe("/ase operational home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/ase/[domain] layout", () => {
|
||||
describe("/admin-next/[domain] layout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects an unknown domain before loading capability context", async () => {
|
||||
await expect(
|
||||
HousekeepingDomainLayout({
|
||||
AdminNextDomainLayout({
|
||||
children: createElement("p", null, "Unknown body"),
|
||||
params: Promise.resolve({ domain: "unknown" }),
|
||||
}),
|
||||
@@ -663,7 +472,7 @@ describe("/ase/[domain] layout", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
HousekeepingDomainLayout({
|
||||
AdminNextDomainLayout({
|
||||
children: createElement("p", null, "Economy body"),
|
||||
params: Promise.resolve({ domain: "economy" }),
|
||||
}),
|
||||
@@ -680,7 +489,7 @@ describe("/ase/[domain] layout", () => {
|
||||
);
|
||||
|
||||
const html = await renderRoute(
|
||||
HousekeepingDomainLayout({
|
||||
AdminNextDomainLayout({
|
||||
children: createElement("p", null, "People body"),
|
||||
params: Promise.resolve({ domain: "people" }),
|
||||
}),
|
||||
@@ -691,7 +500,7 @@ describe("/ase/[domain] layout", () => {
|
||||
expect(html).toContain("HK::primary-navigation");
|
||||
expect(html).toContain("HK::contextual-navigation");
|
||||
expect(html).toContain("HK::command-disabled");
|
||||
expect(html).not.toContain("HK::preview-badge");
|
||||
expect(html).toContain("HK::preview-badge");
|
||||
expect(html).toContain("HK::back-to-site");
|
||||
expect(html).toContain("HK::people-title");
|
||||
expect(html).toContain("People body");
|
||||
@@ -704,153 +513,36 @@ describe("/ase/[domain] layout", () => {
|
||||
);
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("builds and translates the complete real System navigation", async () => {
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue({
|
||||
...capabilityContext([]),
|
||||
isSuperAdmin: true,
|
||||
});
|
||||
|
||||
const html = await renderRoute(
|
||||
HousekeepingDomainLayout({
|
||||
children: createElement("p", null, "System body"),
|
||||
params: Promise.resolve({ domain: "system" }),
|
||||
}),
|
||||
);
|
||||
|
||||
for (const label of [
|
||||
"HK::system-access-permissions",
|
||||
"HK::system-access-permission-detail",
|
||||
"HK::system-configuration-settings",
|
||||
"HK::system-configuration-emulator",
|
||||
"HK::system-observability-analytics",
|
||||
"HK::system-observability-analytics-activity",
|
||||
"HK::system-observability-analytics-economy",
|
||||
"HK::system-observability-devops",
|
||||
"HK::system-observability-devops-errors",
|
||||
"HK::system-observability-logs-staff",
|
||||
"HK::system-observability-logs-audit",
|
||||
"HK::system-observability-logs-chat",
|
||||
"HK::system-observability-logs-commands",
|
||||
"HK::system-observability-logs-trades",
|
||||
"HK::system-operations-alerts",
|
||||
"HK::system-operations-command-center",
|
||||
"HK::system-operations-maintenance",
|
||||
]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
expect(html).toContain("HK::system-title");
|
||||
expect(html).toContain("System body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/ase/[domain]/[[...segments]] page", () => {
|
||||
describe("/admin-next/[domain] page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps a permitted canonical path and renders its handler with one context", async () => {
|
||||
const context = capabilityContext([PERMS.MOD_CFH_VIEW]);
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
it("renders the real localized manifest and empty state without reloading access", async () => {
|
||||
const html = await renderRoute(
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({ domain: "people", segments: ["users"] }),
|
||||
AdminNextDomainPage({
|
||||
params: Promise.resolve({ domain: "people" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("Rendered people.users for refreshed-moderator");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.users",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/users",
|
||||
},
|
||||
});
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
|
||||
expect(html).toContain("HK::people-title");
|
||||
expect(html).toContain("Localized People description");
|
||||
expect(html).toContain("Localized empty title");
|
||||
expect(html).toContain("Localized empty description");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps nested canonical segments to canonical dynamic parameters", async () => {
|
||||
const context = capabilityContext([PERMS.USERS_VIEW]);
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
await renderRoute(
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({
|
||||
domain: "people",
|
||||
segments: ["users", "42"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.user-detail",
|
||||
domain: "people",
|
||||
params: { id: "42" },
|
||||
canonicalHref: "/ase/people/users/42",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps catch-all encoding from changing the literal route handler", async () => {
|
||||
const context = capabilityContext([PERMS.USERS_VIEW]);
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
await renderRoute(
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({
|
||||
domain: "people",
|
||||
segments: ["tools", "a+b[1]"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.literal-tool",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/tools/a%2Bb%5B1%5D",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 404 for an inaccessible matched route without invoking it", async () => {
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||
);
|
||||
|
||||
it("rejects an unknown domain before translating", async () => {
|
||||
await expect(
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({ domain: "people", segments: ["bans"] }),
|
||||
}),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(routeMocks.renderHousekeepingRoute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown route before loading capability context", async () => {
|
||||
await expect(
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({ domain: "people", segments: ["unknown"] }),
|
||||
AdminNextDomainPage({
|
||||
params: Promise.resolve({ domain: "unknown" }),
|
||||
}),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
||||
expect(routeMocks.renderHousekeepingRoute).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -869,247 +561,247 @@ describe("preview route import boundary", () => {
|
||||
it.each([
|
||||
[
|
||||
"relative database import with resolver extension",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../../lib/db.js";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"aliased database import with resolver extension",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "@/lib/db.js";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"action root import with resolver extension",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import actions from "../../actions.mjs";',
|
||||
"src/actions",
|
||||
],
|
||||
[
|
||||
"legacy mod root import with resolver extension",
|
||||
"src/app/ase/layout.tsx",
|
||||
"src/app/admin-next/layout.tsx",
|
||||
'import mod from "../mod.cjs";',
|
||||
"src/app/mod",
|
||||
],
|
||||
[
|
||||
"database import with query suffix",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../../lib/db?server-only";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"action import with hash suffix",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import("../../actions/users#server")',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"Windows-style relative database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
String.raw`import db from "..\\..\\lib\\db";`,
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"Windows-style aliased database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
String.raw`import db from "@\\lib\\db";`,
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"percent-encoded database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../../lib/%64%62";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"optional CommonJS database require",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'require?.("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"module database require",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'module.require("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"require.resolve database access",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'require.resolve("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"optional module database require",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'module.require?.("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"optional require.resolve database access",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'require.resolve?.("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"TypeScript import-equals database access",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db = require("../../lib/db");',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"U+2028 line-continuation database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../\\' + "\u2028" + '../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"U+2029 line-continuation database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../\\' + "\u2029" + '../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"parenthesized dynamic action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import(("../../actions/users"))',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"regex-brace template-expression action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
`const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`,
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"CommonJS database require",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'require("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"template-literal dynamic action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
"import(`../../actions/users`)",
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"TypeScript-asserted dynamic action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import(("../../actions/users" as string))',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"template-expression dynamic action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
`const x = \`${interpolationOpen}import("../../actions/users")}\`;`,
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"nested template-expression dynamic action import",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
|
||||
"src/app/admin-next/[domain]/page.tsx",
|
||||
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`,
|
||||
"src/actions/nested",
|
||||
],
|
||||
[
|
||||
"unicode escaped dynamic app-action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import("\\u002e\\u002e/actions/users")',
|
||||
"src/app/actions/users",
|
||||
],
|
||||
[
|
||||
"code-point escaped dynamic app-action import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import("\\u{2e}\\u{2e}/actions/users")',
|
||||
"src/app/actions/users",
|
||||
],
|
||||
[
|
||||
"hex escaped export-from auth import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";',
|
||||
"src/lib/auth",
|
||||
],
|
||||
[
|
||||
"escaped-slash permissions import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import permissions from "..\\/..\\/lib\\/permissions";',
|
||||
"src/lib/permissions",
|
||||
],
|
||||
[
|
||||
"unknown escape database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../../\\lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"line-continuation database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import db from "../\\' + "\n" + '../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"aliased database descendant import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import { query } from "@/lib/db/query";',
|
||||
"src/lib/db/query",
|
||||
],
|
||||
[
|
||||
"root relative database import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import { db } from "../../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"domain relative auth side-effect import",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
"src/app/admin-next/[domain]/layout.tsx",
|
||||
'import "../../../lib/auth";',
|
||||
"src/lib/auth",
|
||||
],
|
||||
[
|
||||
"domain relative permissions export",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
'export { getAdminContext } from "../../../../lib/permissions";',
|
||||
"src/app/admin-next/[domain]/page.tsx",
|
||||
'export { getAdminContext } from "../../../lib/permissions";',
|
||||
"src/lib/permissions",
|
||||
],
|
||||
[
|
||||
"root relative action dynamic import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import("../../actions/users")',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"root relative app action export",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'export * from "../actions";',
|
||||
"src/app/actions",
|
||||
],
|
||||
[
|
||||
"domain relative legacy admin import",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
"src/app/admin-next/[domain]/layout.tsx",
|
||||
'import page from "../../admin/users/page";',
|
||||
"src/app/admin/users/page",
|
||||
],
|
||||
[
|
||||
"root relative legacy mod dynamic import",
|
||||
"src/app/ase/layout.tsx",
|
||||
"src/app/admin-next/layout.tsx",
|
||||
'import("../mod/users/page")',
|
||||
"src/app/mod/users/page",
|
||||
],
|
||||
[
|
||||
"Prisma TypeScript import type",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'type PrismaClient = import("@prisma/client").PrismaClient;',
|
||||
"@prisma/client",
|
||||
],
|
||||
[
|
||||
"Drizzle package import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import { sql } from "drizzle-orm";',
|
||||
"drizzle-orm",
|
||||
],
|
||||
[
|
||||
"mysql2 package import",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import type { Pool } from "mysql2";',
|
||||
"mysql2",
|
||||
],
|
||||
@@ -1142,7 +834,7 @@ describe("preview route import boundary", () => {
|
||||
],
|
||||
] as const)("fails closed for non-literal %s", (_name, source, violation) => {
|
||||
expect(
|
||||
findRouteImportBoundaryViolations(source, "src/app/ase/page.tsx"),
|
||||
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
|
||||
).toContain(violation);
|
||||
});
|
||||
|
||||
@@ -1151,7 +843,7 @@ describe("preview route import boundary", () => {
|
||||
'import database from "@/lib/database.js?raw";',
|
||||
'import dbTools from "../../lib/db-tools.ts";',
|
||||
'import auth from "../../lib/authentication";',
|
||||
'import preview from "../ase-shared";',
|
||||
'import preview from "../admin-next-shared";',
|
||||
'import prismaTools from "@prisma/client-tools";',
|
||||
'import drizzleTools from "drizzle-orm-kit";',
|
||||
'import mysqlTools from "mysql2-wrapper";',
|
||||
@@ -1161,7 +853,7 @@ describe("preview route import boundary", () => {
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
findRouteImportBoundaryViolations(source, "src/app/ase/page.tsx"),
|
||||
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { orchestrateProviders, type ProviderPolicy } from "./orchestrate";
|
||||
import type { HousekeepingProvider } from "./run-provider";
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
label: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const policy: ProviderPolicy<Item> = {
|
||||
timeoutMs: 2_000,
|
||||
perProviderLimit: 2,
|
||||
combinedLimit: 3,
|
||||
sort: (items) => [...items].sort((left, right) => left.order - right.order),
|
||||
dedupeKey: (item) => item.key,
|
||||
};
|
||||
|
||||
const allowedContext: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 0 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === "housekeeping.read",
|
||||
hasAny: (...slugs) => slugs.includes("housekeeping.read"),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === "housekeeping.read"),
|
||||
};
|
||||
|
||||
function provider(
|
||||
id: string,
|
||||
items: readonly Item[],
|
||||
): HousekeepingProvider<Item> {
|
||||
return {
|
||||
id,
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async () => ok(items, `${id}-correlation`),
|
||||
};
|
||||
}
|
||||
|
||||
describe("orchestrateProviders", () => {
|
||||
it("preserves a successful sibling when another provider times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const slow: HousekeepingProvider<Item> = {
|
||||
id: "slow",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async (_context, signal) =>
|
||||
await new Promise((_, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("aborted")));
|
||||
}),
|
||||
};
|
||||
|
||||
const result = orchestrateProviders(
|
||||
[slow, provider("fast", [{ key: "fast", label: "Fast", order: 1 }])],
|
||||
allowedContext,
|
||||
policy,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(await result).toMatchObject({
|
||||
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||
errors: [{ providerId: "slow", code: "TIMEOUT" }],
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("preserves a successful sibling when another provider throws synchronously", async () => {
|
||||
const synchronousFailure: HousekeepingProvider<Item> = {
|
||||
id: "synchronously-broken",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: () => {
|
||||
throw new Error("synchronous dependency failure");
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
await orchestrateProviders(
|
||||
[
|
||||
synchronousFailure,
|
||||
provider("fast", [{ key: "fast", label: "Fast", order: 1 }]),
|
||||
],
|
||||
allowedContext,
|
||||
policy,
|
||||
),
|
||||
).toMatchObject({
|
||||
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||
errors: [{ providerId: "synchronously-broken", code: "INTERNAL" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the timeout result and sibling success when a provider rejects late", async () => {
|
||||
vi.useFakeTimers();
|
||||
let observedAbort = false;
|
||||
const lateFailure: HousekeepingProvider<Item> = {
|
||||
id: "late-failure",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async (_context, signal) =>
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
observedAbort = true;
|
||||
});
|
||||
setTimeout(() => reject(new Error("late dependency failure")), 2_001);
|
||||
}),
|
||||
};
|
||||
|
||||
const batch = orchestrateProviders(
|
||||
[
|
||||
lateFailure,
|
||||
provider("fast", [{ key: "fast", label: "Fast", order: 1 }]),
|
||||
],
|
||||
allowedContext,
|
||||
policy,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(await batch).toMatchObject({
|
||||
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||
errors: [{ providerId: "late-failure", code: "TIMEOUT" }],
|
||||
});
|
||||
expect(observedAbort).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(await batch).toMatchObject({
|
||||
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||
errors: [{ providerId: "late-failure", code: "TIMEOUT" }],
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps the first duplicate before applying deterministic sorting", async () => {
|
||||
const result = await orchestrateProviders(
|
||||
[
|
||||
provider("first", [
|
||||
{ key: "shared", label: "First", order: 2 },
|
||||
{ key: "unique", label: "Unique", order: 1 },
|
||||
]),
|
||||
provider("second", [{ key: "shared", label: "Second", order: 0 }]),
|
||||
],
|
||||
allowedContext,
|
||||
policy,
|
||||
);
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{ key: "unique", label: "Unique", order: 1 },
|
||||
{ key: "shared", label: "First", order: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("enforces per-provider and combined caps before sorting", async () => {
|
||||
const result = await orchestrateProviders(
|
||||
[
|
||||
provider("first", [
|
||||
{ key: "one", label: "One", order: 3 },
|
||||
{ key: "two", label: "Two", order: 2 },
|
||||
{ key: "discarded", label: "Discarded", order: 0 },
|
||||
]),
|
||||
provider("second", [
|
||||
{ key: "three", label: "Three", order: 1 },
|
||||
{ key: "combined-discarded", label: "Combined discarded", order: 0 },
|
||||
]),
|
||||
],
|
||||
allowedContext,
|
||||
policy,
|
||||
);
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{ key: "three", label: "Three", order: 1 },
|
||||
{ key: "two", label: "Two", order: 2 },
|
||||
{ key: "one", label: "One", order: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the per-provider cap before cross-provider deduplication", async () => {
|
||||
const result = await orchestrateProviders(
|
||||
[
|
||||
provider("first", [{ key: "shared", label: "First", order: 1 }]),
|
||||
provider("second", [
|
||||
{ key: "shared", label: "Second", order: 2 },
|
||||
{ key: "unique", label: "Unique", order: 3 },
|
||||
]),
|
||||
],
|
||||
allowedContext,
|
||||
{ ...policy, perProviderLimit: 1, combinedLimit: 5 },
|
||||
);
|
||||
|
||||
expect(result.items).toEqual([{ key: "shared", label: "First", order: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingErrorCode,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import { type HousekeepingProvider, runProvider } from "./run-provider";
|
||||
|
||||
export interface ProviderPolicy<T> {
|
||||
timeoutMs: number;
|
||||
perProviderLimit: number;
|
||||
combinedLimit: number;
|
||||
sort(items: readonly T[]): readonly T[];
|
||||
dedupeKey(item: T): string;
|
||||
}
|
||||
|
||||
export interface ProviderBatchResult<T> {
|
||||
items: readonly T[];
|
||||
errors: readonly { providerId: string; code: HousekeepingErrorCode }[];
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
export async function orchestrateProviders<T>(
|
||||
providers: readonly HousekeepingProvider<T>[],
|
||||
context: HousekeepingCapabilityContext,
|
||||
policy: ProviderPolicy<T>,
|
||||
): Promise<ProviderBatchResult<T>> {
|
||||
const providerResults = await Promise.all(
|
||||
providers.map((provider) =>
|
||||
runProvider(provider, context, policy.timeoutMs),
|
||||
),
|
||||
);
|
||||
const items: T[] = [];
|
||||
const seen = new Set<string>();
|
||||
const errors: { providerId: string; code: HousekeepingErrorCode }[] = [];
|
||||
|
||||
for (const result of providerResults) {
|
||||
if (result.error !== undefined) {
|
||||
errors.push({ providerId: result.providerId, code: result.error });
|
||||
}
|
||||
|
||||
for (const item of result.items.slice(0, policy.perProviderLimit)) {
|
||||
if (items.length >= policy.combinedLimit) break;
|
||||
const key = policy.dedupeKey(item);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: policy.sort(items),
|
||||
errors,
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { type HousekeepingProvider, runProvider } from "./run-provider";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 999 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => granted.includes(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => granted.includes(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runProvider", () => {
|
||||
it("aborts a slow provider at its 2,000 ms deadline and returns TIMEOUT", async () => {
|
||||
vi.useFakeTimers();
|
||||
let observedAbort = false;
|
||||
const provider: HousekeepingProvider<string> = {
|
||||
id: "slow",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async (_context, signal) =>
|
||||
await new Promise((_, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
observedAbort = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
const result = runProvider(provider, context(["housekeeping.read"]), 2_000);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(await result).toEqual({
|
||||
providerId: "slow",
|
||||
items: [],
|
||||
error: "TIMEOUT",
|
||||
});
|
||||
expect(observedAbort).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips a provider whose required capability is absent", async () => {
|
||||
let invoked = false;
|
||||
const provider: HousekeepingProvider<string> = {
|
||||
id: "restricted",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async () => {
|
||||
invoked = true;
|
||||
return ok(["private"], "provider-correlation");
|
||||
},
|
||||
};
|
||||
|
||||
expect(await runProvider(provider, context([]), 2_000)).toEqual({
|
||||
providerId: "restricted",
|
||||
items: [],
|
||||
});
|
||||
expect(invoked).toBe(false);
|
||||
});
|
||||
|
||||
it("maps one thrown provider failure to one INTERNAL partial error", async () => {
|
||||
const provider: HousekeepingProvider<string> = {
|
||||
id: "broken",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async () => {
|
||||
throw new Error("database unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
await runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||
).toEqual({ providerId: "broken", items: [], error: "INTERNAL" });
|
||||
});
|
||||
|
||||
it("maps a synchronous provider throw to one INTERNAL partial error", async () => {
|
||||
const provider: HousekeepingProvider<string> = {
|
||||
id: "synchronously-broken",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: () => {
|
||||
throw new Error("synchronous dependency failure");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||
).resolves.toEqual({
|
||||
providerId: "synchronously-broken",
|
||||
items: [],
|
||||
error: "INTERNAL",
|
||||
});
|
||||
});
|
||||
|
||||
it("times out an abort-ignoring provider exactly at 2,000 ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
let observedAbort = false;
|
||||
const provider: HousekeepingProvider<string> = {
|
||||
id: "abort-ignoring",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async (_context, signal) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
observedAbort = true;
|
||||
});
|
||||
return await new Promise<never>(() => undefined);
|
||||
},
|
||||
};
|
||||
|
||||
const result = runProvider(provider, context(["housekeeping.read"]), 2_000);
|
||||
let settled = false;
|
||||
void result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
expect(settled).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(await result).toEqual({
|
||||
providerId: "abort-ignoring",
|
||||
items: [],
|
||||
error: "TIMEOUT",
|
||||
});
|
||||
expect(observedAbort).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("preserves a returned typed provider failure code once", async () => {
|
||||
const provider: HousekeepingProvider<string> = {
|
||||
id: "dependency-failure",
|
||||
capability: anyCapability("housekeeping.read"),
|
||||
run: async () =>
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"provider-correlation",
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
await runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||
).toEqual({
|
||||
providerId: "dependency-failure",
|
||||
items: [],
|
||||
error: "DEPENDENCY_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingErrorCode,
|
||||
HousekeepingResult,
|
||||
} from "../contracts";
|
||||
|
||||
export interface HousekeepingProvider<T> {
|
||||
id: string;
|
||||
capability: CapabilityRequirement;
|
||||
run(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<HousekeepingResult<readonly T[]>>;
|
||||
}
|
||||
|
||||
export interface ProviderRunResult<T> {
|
||||
providerId: string;
|
||||
items: readonly T[];
|
||||
error?: HousekeepingErrorCode;
|
||||
}
|
||||
|
||||
export async function runProvider<T>(
|
||||
provider: HousekeepingProvider<T>,
|
||||
context: HousekeepingCapabilityContext,
|
||||
timeoutMs: number,
|
||||
): Promise<ProviderRunResult<T>> {
|
||||
if (!satisfiesCapability(context, provider.capability)) {
|
||||
return { providerId: provider.id, items: [] };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const run = Promise.resolve()
|
||||
.then(() => provider.run(context, controller.signal))
|
||||
.then(
|
||||
(result): ProviderRunResult<T> =>
|
||||
result.ok
|
||||
? { providerId: provider.id, items: result.data }
|
||||
: { providerId: provider.id, items: [], error: result.error.code },
|
||||
)
|
||||
.catch(
|
||||
(): ProviderRunResult<T> => ({
|
||||
providerId: provider.id,
|
||||
items: [],
|
||||
error: "INTERNAL",
|
||||
}),
|
||||
);
|
||||
const timeoutResult = new Promise<ProviderRunResult<T>>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
resolve({ providerId: provider.id, items: [], error: "TIMEOUT" });
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([run, timeoutResult]);
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import {
|
||||
createHousekeepingRecentWorkService,
|
||||
RECENT_WORK_POLICY,
|
||||
} from "./recent-work";
|
||||
|
||||
const usersView = anyCapability(PERMS.USERS_VIEW);
|
||||
const bansView = anyCapability(PERMS.BANS_VIEW);
|
||||
|
||||
function registry(routeCount = 2): HousekeepingRegistry {
|
||||
return {
|
||||
domains: [
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "people",
|
||||
descriptionKey: "people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: usersView,
|
||||
routes: Array.from({ length: routeCount }, (_, index) => ({
|
||||
id: index === 0 ? "people.users" : `people.route-${index}`,
|
||||
labelKey:
|
||||
index === 0 ? "routes.people.users" : `routes.people.${index}`,
|
||||
href:
|
||||
index === 0
|
||||
? "/ase/people/users"
|
||||
: (`/ase/people/route-${index}` as const),
|
||||
capability: index === 1 ? bansView : usersView,
|
||||
})),
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_VIEW,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
describe("housekeeping recent work", () => {
|
||||
it("derives newest authorized route visits and mutation audits, then deduplicates by route", async () => {
|
||||
const loadRows = vi.fn().mockResolvedValue([
|
||||
{
|
||||
action: "housekeeping.route.visit",
|
||||
target: "people.users",
|
||||
domain: "people",
|
||||
outcome: "success",
|
||||
createdAt: "2026-08-30T10:01:00.000Z",
|
||||
},
|
||||
{
|
||||
action: "people.user.update",
|
||||
target: "People",
|
||||
domain: "people",
|
||||
outcome: "success",
|
||||
createdAt: "2026-08-30T10:03:00.000Z",
|
||||
},
|
||||
{
|
||||
action: "housekeeping.route.visit",
|
||||
target: "people.route-1",
|
||||
domain: "people",
|
||||
outcome: "success",
|
||||
createdAt: "2026-08-30T10:04:00.000Z",
|
||||
},
|
||||
{
|
||||
action: "people.user.delete",
|
||||
target: "People",
|
||||
domain: "people",
|
||||
outcome: "failure",
|
||||
createdAt: "2026-08-30T10:05:00.000Z",
|
||||
},
|
||||
{
|
||||
action: "people.unknown.update",
|
||||
target: "People",
|
||||
domain: "people",
|
||||
outcome: "success",
|
||||
createdAt: "2026-08-30T10:06:00.000Z",
|
||||
},
|
||||
]);
|
||||
const service = createHousekeepingRecentWorkService({
|
||||
registry: registry(),
|
||||
loadRows,
|
||||
writeAudit: vi.fn(),
|
||||
createCorrelationId: () => "recent-1",
|
||||
});
|
||||
|
||||
const result = await service.load(context);
|
||||
|
||||
expect(loadRows).toHaveBeenCalledWith(42, RECENT_WORK_POLICY.scanLimit);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: [
|
||||
{
|
||||
routeId: "people.users",
|
||||
canonicalHref: "/ase/people/users",
|
||||
labelKey: "routes.people.users",
|
||||
occurredAt: "2026-08-30T10:03:00.000Z",
|
||||
source: "audit",
|
||||
},
|
||||
],
|
||||
correlationId: "recent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("caps authorized unique recent routes at twelve", async () => {
|
||||
const rows = Array.from({ length: 14 }, (_, index) => ({
|
||||
action: "housekeeping.route.visit",
|
||||
target: index === 0 ? "people.users" : `people.route-${index + 1}`,
|
||||
domain: "people",
|
||||
outcome: "success",
|
||||
createdAt: new Date(Date.UTC(2026, 7, 30, 12, index)).toISOString(),
|
||||
}));
|
||||
const service = createHousekeepingRecentWorkService({
|
||||
registry: registry(16),
|
||||
loadRows: vi.fn().mockResolvedValue(rows),
|
||||
writeAudit: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await service.load(context);
|
||||
|
||||
expect(result.ok && result.data).toHaveLength(RECENT_WORK_POLICY.limit);
|
||||
expect(result.ok && result.data[0]?.occurredAt).toBe(
|
||||
"2026-08-30T12:13:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("records only an authorized canonical route visit for the server actor", async () => {
|
||||
const writeAudit = vi.fn().mockResolvedValue(undefined);
|
||||
const service = createHousekeepingRecentWorkService({
|
||||
registry: registry(),
|
||||
loadRows: vi.fn(),
|
||||
writeAudit,
|
||||
createCorrelationId: () => "visit-1",
|
||||
});
|
||||
|
||||
const allowed = await service.recordVisit("people.users", context);
|
||||
const denied = await service.recordVisit("people.route-1", context);
|
||||
|
||||
expect(allowed).toMatchObject({
|
||||
ok: true,
|
||||
data: { routeId: "people.users", canonicalHref: "/ase/people/users" },
|
||||
});
|
||||
expect(writeAudit).toHaveBeenCalledOnce();
|
||||
expect(writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 42,
|
||||
action: "housekeeping.route.visit",
|
||||
target: "people.users",
|
||||
domain: "people",
|
||||
outcome: "success",
|
||||
correlationId: "visit-1",
|
||||
}),
|
||||
);
|
||||
expect(denied).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import {
|
||||
type CanonicalHousekeepingHref,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
|
||||
export const RECENT_WORK_POLICY = Object.freeze({
|
||||
limit: 12,
|
||||
scanLimit: 200,
|
||||
dedupe: "routeId",
|
||||
} as const);
|
||||
|
||||
export interface HousekeepingRecentItem {
|
||||
readonly routeId: string;
|
||||
readonly canonicalHref: CanonicalHousekeepingHref;
|
||||
readonly labelKey: string;
|
||||
readonly occurredAt: string;
|
||||
readonly source: "route-visit" | "audit";
|
||||
}
|
||||
|
||||
export interface HousekeepingRecentAuditRow {
|
||||
readonly action: string;
|
||||
readonly target: string;
|
||||
readonly domain: string | null;
|
||||
readonly outcome: string | null;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
interface RegisteredRoute {
|
||||
readonly domain: HousekeepingDomainManifest;
|
||||
readonly route: HousekeepingDomainManifest["routes"][number];
|
||||
}
|
||||
|
||||
export interface HousekeepingRecentWorkDependencies {
|
||||
readonly registry: HousekeepingRegistry;
|
||||
readonly loadRows: (
|
||||
userId: number,
|
||||
limit: number,
|
||||
) => Promise<readonly HousekeepingRecentAuditRow[]>;
|
||||
readonly writeAudit: (entry: AuditEntry) => Promise<void>;
|
||||
readonly createCorrelationId?: () => string;
|
||||
}
|
||||
|
||||
const completedOutcomes = new Set(["success", "partial"]);
|
||||
const genericActionTokens = new Set([
|
||||
"add",
|
||||
"apply",
|
||||
"change",
|
||||
"create",
|
||||
"delete",
|
||||
"execute",
|
||||
"remove",
|
||||
"runtime",
|
||||
"save",
|
||||
"set",
|
||||
"toggle",
|
||||
"update",
|
||||
]);
|
||||
|
||||
function registeredRoutes(
|
||||
registry: HousekeepingRegistry,
|
||||
): readonly RegisteredRoute[] {
|
||||
return registry.domains.flatMap((domain) =>
|
||||
domain.routes.map((route) => ({ domain, route })),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizedTokens(value: string): readonly string[] {
|
||||
return value
|
||||
.toLocaleLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
if (token.endsWith("ies") && token.length > 3) {
|
||||
return `${token.slice(0, -3)}y`;
|
||||
}
|
||||
if (token.endsWith("s") && token.length > 3) return token.slice(0, -1);
|
||||
return token;
|
||||
})
|
||||
.filter((token) => !genericActionTokens.has(token));
|
||||
}
|
||||
|
||||
function authorized(
|
||||
entry: RegisteredRoute,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): boolean {
|
||||
return (
|
||||
satisfiesCapability(context, entry.domain.capability) &&
|
||||
satisfiesCapability(context, entry.route.capability)
|
||||
);
|
||||
}
|
||||
|
||||
function routeForAudit(
|
||||
row: HousekeepingRecentAuditRow,
|
||||
routes: readonly RegisteredRoute[],
|
||||
context: HousekeepingCapabilityContext,
|
||||
): RegisteredRoute | undefined {
|
||||
const domainRoutes = routes.filter(
|
||||
(entry) => entry.domain.id === row.domain && authorized(entry, context),
|
||||
);
|
||||
const exact = domainRoutes.find((entry) => entry.route.id === row.target);
|
||||
if (exact) return exact;
|
||||
|
||||
const actionTokens = new Set(
|
||||
normalizedTokens(row.action).filter((token) => token !== row.domain),
|
||||
);
|
||||
let best: { entry: RegisteredRoute; score: number } | undefined;
|
||||
for (const entry of domainRoutes) {
|
||||
const score = normalizedTokens(entry.route.id)
|
||||
.filter((token) => token !== row.domain)
|
||||
.reduce((total, token) => total + (actionTokens.has(token) ? 1 : 0), 0);
|
||||
if (score > 0 && (!best || score > best.score)) best = { entry, score };
|
||||
}
|
||||
return best?.entry;
|
||||
}
|
||||
|
||||
function recentItem(
|
||||
row: HousekeepingRecentAuditRow,
|
||||
route: RegisteredRoute,
|
||||
): HousekeepingRecentItem {
|
||||
return {
|
||||
routeId: route.route.id,
|
||||
canonicalHref: route.route.href,
|
||||
labelKey: route.route.labelKey,
|
||||
occurredAt: row.createdAt,
|
||||
source: row.action === "housekeeping.route.visit" ? "route-visit" : "audit",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveHousekeepingRecentWork(
|
||||
rows: readonly HousekeepingRecentAuditRow[],
|
||||
registry: HousekeepingRegistry,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): readonly HousekeepingRecentItem[] {
|
||||
const routes = registeredRoutes(registry);
|
||||
const sorted = [...rows].sort(
|
||||
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const items: HousekeepingRecentItem[] = [];
|
||||
|
||||
for (const row of sorted) {
|
||||
if (!completedOutcomes.has(row.outcome ?? "")) continue;
|
||||
if (!Number.isFinite(Date.parse(row.createdAt))) continue;
|
||||
|
||||
const route =
|
||||
row.action === "housekeeping.route.visit"
|
||||
? routes.find(
|
||||
(entry) =>
|
||||
entry.route.id === row.target && authorized(entry, context),
|
||||
)
|
||||
: routeForAudit(row, routes, context);
|
||||
if (!route || seen.has(route.route.id)) continue;
|
||||
seen.add(route.route.id);
|
||||
items.push(recentItem(row, route));
|
||||
if (items.length === RECENT_WORK_POLICY.limit) break;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function createHousekeepingRecentWorkService({
|
||||
registry,
|
||||
loadRows,
|
||||
writeAudit,
|
||||
createCorrelationId: correlationIdFactory = createCorrelationId,
|
||||
}: HousekeepingRecentWorkDependencies) {
|
||||
return {
|
||||
async load(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>> {
|
||||
const correlationId = correlationIdFactory();
|
||||
try {
|
||||
const rows = await loadRows(
|
||||
context.actor.id,
|
||||
RECENT_WORK_POLICY.scanLimit,
|
||||
);
|
||||
return ok(
|
||||
deriveHousekeepingRecentWork(rows, registry, context),
|
||||
correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"INTERNAL",
|
||||
"errors.housekeeping.recent.read",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async recordVisit(
|
||||
routeId: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
|
||||
const correlationId = correlationIdFactory();
|
||||
const entry = registeredRoutes(registry).find(
|
||||
(candidate) => candidate.route.id === routeId,
|
||||
);
|
||||
if (!entry) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.recent.routeNotFound",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
if (!authorized(entry, context)) {
|
||||
return fail(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const occurredAt = new Date().toISOString();
|
||||
try {
|
||||
await writeAudit({
|
||||
userId: context.actor.id,
|
||||
action: "housekeeping.route.visit",
|
||||
target: entry.route.id,
|
||||
after: {
|
||||
canonicalHref: entry.route.href,
|
||||
labelKey: entry.route.labelKey,
|
||||
},
|
||||
correlationId,
|
||||
outcome: "success",
|
||||
domain: entry.domain.id,
|
||||
});
|
||||
return ok(
|
||||
{
|
||||
routeId: entry.route.id,
|
||||
canonicalHref: entry.route.href,
|
||||
labelKey: entry.route.labelKey,
|
||||
occurredAt,
|
||||
source: "route-visit",
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"INTERNAL",
|
||||
"errors.housekeeping.recent.record",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,29 +1,5 @@
|
||||
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 { ECONOMY_INBOX_SOURCES } from "../domains/economy/inbox";
|
||||
import { ECONOMY_ROUTES } from "../domains/economy/routes";
|
||||
import { ECONOMY_SEARCH_PROVIDERS } from "../domains/economy/search";
|
||||
import { ECONOMY_WIDGETS } from "../domains/economy/widgets";
|
||||
import { HOTEL_INBOX_SOURCES } from "../domains/hotel/inbox";
|
||||
import { HOTEL_ROUTES } from "../domains/hotel/routes";
|
||||
import { HOTEL_SEARCH_PROVIDERS } from "../domains/hotel/search";
|
||||
import { HOTEL_WIDGETS } from "../domains/hotel/widgets";
|
||||
import { OPERATIONS_INBOX_SOURCES } from "../domains/operations/inbox";
|
||||
import { OPERATIONS_ROUTES } from "../domains/operations/routes";
|
||||
import { OPERATIONS_SEARCH_PROVIDERS } from "../domains/operations/search";
|
||||
import { OPERATIONS_WIDGETS } from "../domains/operations/widgets";
|
||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
import { PEOPLE_WIDGETS } from "../domains/people/widgets";
|
||||
import { SYSTEM_INBOX_SOURCES } from "../domains/system/inbox";
|
||||
import { SYSTEM_ROUTES } from "../domains/system/routes";
|
||||
import { SYSTEM_SEARCH_PROVIDERS } from "../domains/system/search";
|
||||
import { SYSTEM_WIDGETS } from "../domains/system/widgets";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
import {
|
||||
@@ -32,21 +8,14 @@ import {
|
||||
} from "../migration/types";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type CapabilityRequirement,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingSearchProvider,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "./contracts";
|
||||
import { defaultHousekeepingPreferences } from "./preferences/schema";
|
||||
import {
|
||||
createHousekeepingRegistry,
|
||||
loadHousekeepingWidgets,
|
||||
WIDGET_POLICY,
|
||||
} from "./registry";
|
||||
import { createHousekeepingRegistry } from "./registry";
|
||||
|
||||
const manifest = (
|
||||
id: HousekeepingDomainId,
|
||||
@@ -56,7 +25,7 @@ const manifest = (
|
||||
labelKey: `pages.housekeeping.domains.${id}.title`,
|
||||
descriptionKey: `pages.housekeeping.domains.${id}.description`,
|
||||
iconId: "settings",
|
||||
canonicalHref: id === "operations" ? "/ase" : `/ase/${id}`,
|
||||
previewHref: `/admin-next/${id}`,
|
||||
capability: anyCapability(capabilitySlug),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
@@ -104,7 +73,7 @@ const expectedManifests = [
|
||||
{
|
||||
id: "operations",
|
||||
iconId: "inbox",
|
||||
canonicalHref: "/ase",
|
||||
previewHref: "/admin-next/operations",
|
||||
labelKey: "pages.housekeeping.domains.operations.title",
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
slugs: [PERMS.ADMIN_DASHBOARD],
|
||||
@@ -112,7 +81,7 @@ const expectedManifests = [
|
||||
{
|
||||
id: "people",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
previewHref: "/admin-next/people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
slugs: [
|
||||
@@ -143,7 +112,7 @@ const expectedManifests = [
|
||||
{
|
||||
id: "content",
|
||||
iconId: "file-text",
|
||||
canonicalHref: "/ase/content",
|
||||
previewHref: "/admin-next/content",
|
||||
labelKey: "pages.housekeeping.domains.content.title",
|
||||
descriptionKey: "pages.housekeeping.domains.content.description",
|
||||
slugs: [
|
||||
@@ -166,7 +135,7 @@ const expectedManifests = [
|
||||
{
|
||||
id: "economy",
|
||||
iconId: "gem",
|
||||
canonicalHref: "/ase/economy",
|
||||
previewHref: "/admin-next/economy",
|
||||
labelKey: "pages.housekeeping.domains.economy.title",
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
slugs: [
|
||||
@@ -179,7 +148,7 @@ const expectedManifests = [
|
||||
{
|
||||
id: "hotel",
|
||||
iconId: "hotel",
|
||||
canonicalHref: "/ase/hotel",
|
||||
previewHref: "/admin-next/hotel",
|
||||
labelKey: "pages.housekeeping.domains.hotel.title",
|
||||
descriptionKey: "pages.housekeeping.domains.hotel.description",
|
||||
slugs: [
|
||||
@@ -196,7 +165,7 @@ const expectedManifests = [
|
||||
{
|
||||
id: "system",
|
||||
iconId: "settings",
|
||||
canonicalHref: "/ase/system",
|
||||
previewHref: "/admin-next/system",
|
||||
labelKey: "pages.housekeeping.domains.system.title",
|
||||
descriptionKey: "pages.housekeeping.domains.system.description",
|
||||
slugs: [
|
||||
@@ -239,9 +208,9 @@ describe("housekeeping registry", () => {
|
||||
it("rejects invalid preview and empty translation keys", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), canonicalHref: "/ase" },
|
||||
{ ...manifest("people"), previewHref: "/admin-next/operations" },
|
||||
]),
|
||||
).toThrow("invalid canonical href");
|
||||
).toThrow("invalid preview href");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([{ ...manifest("people"), labelKey: " " }]),
|
||||
).toThrow("empty label key");
|
||||
@@ -263,13 +232,13 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
href: "/admin-next/people/users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/staff",
|
||||
href: "/admin-next/people/staff",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
@@ -284,13 +253,13 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
href: "/admin-next/people/users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
{
|
||||
id: "staff",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
href: "/admin-next/people/users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
@@ -299,37 +268,11 @@ describe("housekeeping registry", () => {
|
||||
).toThrow("duplicate route href");
|
||||
});
|
||||
|
||||
it("rejects duplicate dynamic route shapes regardless of parameter name", () => {
|
||||
const base = manifest("people");
|
||||
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...base,
|
||||
routes: [
|
||||
{
|
||||
id: "people.user-detail",
|
||||
labelKey: "pages.housekeeping.routes.people.user-detail",
|
||||
href: "/ase/people/users/:id",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
{
|
||||
id: "people.user-profile",
|
||||
labelKey: "pages.housekeeping.routes.people.user-profile",
|
||||
href: "/ase/people/users/:username",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate dynamic route shape");
|
||||
});
|
||||
|
||||
it("rejects empty route fields and unknown capability slugs", () => {
|
||||
const route = {
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
href: "/admin-next/people/users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
@@ -340,10 +283,7 @@ describe("housekeeping registry", () => {
|
||||
).toThrow("empty route id");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
routes: [{ ...route, href: " " as CanonicalHousekeepingHref }],
|
||||
},
|
||||
{ ...manifest("people"), routes: [{ ...route, href: " " }] },
|
||||
]),
|
||||
).toThrow("empty route href");
|
||||
expect(() =>
|
||||
@@ -379,70 +319,14 @@ describe("housekeeping registry", () => {
|
||||
expect(actual).toMatchObject({
|
||||
id: expected.id,
|
||||
iconId: expected.iconId,
|
||||
canonicalHref: expected.canonicalHref,
|
||||
previewHref: expected.previewHref,
|
||||
labelKey: expected.labelKey,
|
||||
descriptionKey: expected.descriptionKey,
|
||||
});
|
||||
expect(actual.routes).toEqual(
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_ROUTES
|
||||
: expected.id === "people"
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "content"
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual(
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_SEARCH_PROVIDERS
|
||||
: expected.id === "people"
|
||||
? PEOPLE_SEARCH_PROVIDERS
|
||||
: expected.id === "content"
|
||||
? CONTENT_SEARCH_PROVIDERS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_SEARCH_PROVIDERS
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_SEARCH_PROVIDERS
|
||||
: expected.id === "system"
|
||||
? SYSTEM_SEARCH_PROVIDERS
|
||||
: [],
|
||||
);
|
||||
expect(actual.inboxSources).toEqual(
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_INBOX_SOURCES
|
||||
: expected.id === "people"
|
||||
? PEOPLE_INBOX_SOURCES
|
||||
: expected.id === "content"
|
||||
? CONTENT_INBOX_SOURCES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_INBOX_SOURCES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_INBOX_SOURCES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_INBOX_SOURCES
|
||||
: [],
|
||||
);
|
||||
expect(actual.widgets).toEqual(
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_WIDGETS
|
||||
: expected.id === "people"
|
||||
? PEOPLE_WIDGETS
|
||||
: expected.id === "content"
|
||||
? CONTENT_WIDGETS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_WIDGETS
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_WIDGETS
|
||||
: expected.id === "system"
|
||||
? SYSTEM_WIDGETS
|
||||
: [],
|
||||
);
|
||||
expect(actual.routes).toEqual([]);
|
||||
expect(actual.searchProviders).toEqual([]);
|
||||
expect(actual.inboxSources).toEqual([]);
|
||||
expect(actual.widgets).toEqual([]);
|
||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||
}
|
||||
});
|
||||
@@ -467,7 +351,7 @@ describe("housekeeping registry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicates within each provider kind across domain manifests", () => {
|
||||
it("rejects duplicate provider and widget ids across domain manifests", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
||||
@@ -503,7 +387,7 @@ describe("housekeeping registry", () => {
|
||||
widgets: [widget("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).not.toThrow();
|
||||
).toThrow("duplicate widget id: shared");
|
||||
});
|
||||
|
||||
it("rejects provider and widget ownership outside their manifest", () => {
|
||||
@@ -583,88 +467,6 @@ describe("housekeeping registry", () => {
|
||||
).toThrow("invalid widget kind: people.queue");
|
||||
});
|
||||
|
||||
it("loads only authorized mandatory and enabled optional widgets with isolated timeouts", async () => {
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_VIEW,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
|
||||
};
|
||||
let deniedCalls = 0;
|
||||
const base = {
|
||||
...manifest("people"),
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
const registry = createHousekeepingRegistry([
|
||||
{
|
||||
...base,
|
||||
widgets: [
|
||||
{
|
||||
...widget("people.required"),
|
||||
kind: "mandatory",
|
||||
load: async () => ok("required", "required"),
|
||||
},
|
||||
{
|
||||
...widget("people.optional"),
|
||||
load: async () => ok("optional", "optional"),
|
||||
},
|
||||
{
|
||||
...widget(
|
||||
"people.denied",
|
||||
"people",
|
||||
anyCapability(PERMS.BANS_VIEW),
|
||||
),
|
||||
load: async () => {
|
||||
deniedCalls += 1;
|
||||
return ok("denied", "denied");
|
||||
},
|
||||
},
|
||||
{
|
||||
...widget("people.slow"),
|
||||
load: async (_context, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () =>
|
||||
resolve(ok("late", "late")),
|
||||
);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const preferences = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
widgetOrder: [
|
||||
"stale.widget",
|
||||
"people.optional",
|
||||
"people.required",
|
||||
"people.slow",
|
||||
],
|
||||
enabledOptionalWidgetIds: [
|
||||
"people.optional",
|
||||
"people.denied",
|
||||
"people.slow",
|
||||
],
|
||||
};
|
||||
|
||||
const result = await loadHousekeepingWidgets(
|
||||
registry,
|
||||
preferences,
|
||||
context,
|
||||
5,
|
||||
);
|
||||
|
||||
expect(WIDGET_POLICY.timeoutMs).toBe(2_000);
|
||||
expect(result.widgets).toEqual([
|
||||
{ id: "people.optional", data: "optional" },
|
||||
{ id: "people.required", data: "required" },
|
||||
]);
|
||||
expect(result.errors).toEqual([
|
||||
{ widgetId: "people.slow", code: "TIMEOUT" },
|
||||
]);
|
||||
expect(deniedCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects empty domain and route capability requirements", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
@@ -679,7 +481,7 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
href: "/admin-next/people/users",
|
||||
capability: { mode: "any", slugs: [] },
|
||||
},
|
||||
],
|
||||
@@ -692,7 +494,7 @@ describe("housekeeping registry", () => {
|
||||
const route = {
|
||||
id: "shared",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/shared" as CanonicalHousekeepingHref,
|
||||
href: "/admin-next/shared",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
@@ -701,12 +503,7 @@ describe("housekeeping registry", () => {
|
||||
{ ...manifest("people"), routes: [route] },
|
||||
{
|
||||
...manifest("content"),
|
||||
routes: [
|
||||
{
|
||||
...route,
|
||||
href: "/ase/content/shared" as CanonicalHousekeepingHref,
|
||||
},
|
||||
],
|
||||
routes: [{ ...route, href: "/admin-next/content/shared" }],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate route id");
|
||||
@@ -721,25 +518,3 @@ describe("housekeeping registry", () => {
|
||||
).toThrow("duplicate route href");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the canonical operations root and rejects legacy admin hrefs", () => {
|
||||
const canonicalOperations = manifest("operations") as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete canonicalOperations.canonicalHref;
|
||||
canonicalOperations.canonicalHref = "/ase";
|
||||
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
canonicalOperations as unknown as HousekeepingDomainManifest,
|
||||
]),
|
||||
).not.toThrow();
|
||||
|
||||
canonicalOperations.canonicalHref = "/admin";
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
canonicalOperations as unknown as HousekeepingDomainManifest,
|
||||
]),
|
||||
).toThrow("invalid canonical href");
|
||||
});
|
||||
@@ -1,20 +1,9 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
|
||||
import { satisfiesCapability } from "./capability-context";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingDomainManifest,
|
||||
HousekeepingErrorCode,
|
||||
HousekeepingWidgetDefinition,
|
||||
} from "./contracts";
|
||||
import { ok } from "./contracts";
|
||||
import { reconcilePreferences } from "./preferences/reconcile";
|
||||
import type { HousekeepingPreferences } from "./preferences/schema";
|
||||
import {
|
||||
type HousekeepingProvider,
|
||||
runProvider,
|
||||
} from "./providers/run-provider";
|
||||
|
||||
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
||||
@@ -28,86 +17,13 @@ export interface HousekeepingRegistry {
|
||||
domains: readonly HousekeepingDomainManifest[];
|
||||
}
|
||||
|
||||
export const WIDGET_POLICY = Object.freeze({ timeoutMs: 2_000 } as const);
|
||||
|
||||
export interface HousekeepingWidgetLoadResult {
|
||||
readonly widgets: readonly { readonly id: string; readonly data: unknown }[];
|
||||
readonly errors: readonly {
|
||||
readonly widgetId: string;
|
||||
readonly code: HousekeepingErrorCode;
|
||||
}[];
|
||||
}
|
||||
|
||||
function widgetProvider(
|
||||
widget: HousekeepingWidgetDefinition,
|
||||
): HousekeepingProvider<unknown> {
|
||||
return {
|
||||
id: widget.id,
|
||||
capability: widget.capability,
|
||||
async run(context, signal) {
|
||||
const result = await widget.load(context, signal);
|
||||
return result.ok ? ok([result.data], result.correlationId) : result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadHousekeepingWidgets(
|
||||
registry: HousekeepingRegistry,
|
||||
preferences: HousekeepingPreferences,
|
||||
context: HousekeepingCapabilityContext,
|
||||
timeoutMs: number = WIDGET_POLICY.timeoutMs,
|
||||
): Promise<HousekeepingWidgetLoadResult> {
|
||||
const reconciled = reconcilePreferences(preferences, registry, context);
|
||||
const enabledOptionalIds = new Set(reconciled.enabledOptionalWidgetIds);
|
||||
const selected = registry.domains
|
||||
.filter((domain) => satisfiesCapability(context, domain.capability))
|
||||
.flatMap((domain) => domain.widgets)
|
||||
.filter(
|
||||
(widget) =>
|
||||
satisfiesCapability(context, widget.capability) &&
|
||||
(widget.kind === "mandatory" || enabledOptionalIds.has(widget.id)),
|
||||
);
|
||||
const selectedById = new Map(selected.map((widget) => [widget.id, widget]));
|
||||
const orderedIds = reconciled.widgetOrder.filter((id) =>
|
||||
selectedById.has(id),
|
||||
);
|
||||
for (const widget of selected) {
|
||||
if (!orderedIds.includes(widget.id)) orderedIds.push(widget.id);
|
||||
}
|
||||
const ordered = orderedIds.flatMap((id) => {
|
||||
const widget = selectedById.get(id);
|
||||
return widget ? [widget] : [];
|
||||
});
|
||||
const outcomes = await Promise.all(
|
||||
ordered.map((widget) =>
|
||||
runProvider(widgetProvider(widget), context, timeoutMs),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
widgets: outcomes.flatMap((outcome) =>
|
||||
outcome.error === undefined
|
||||
? [{ id: outcome.providerId, data: outcome.items[0] }]
|
||||
: [],
|
||||
),
|
||||
errors: outcomes.flatMap((outcome) =>
|
||||
outcome.error === undefined
|
||||
? []
|
||||
: [{ widgetId: outcome.providerId, code: outcome.error }],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHousekeepingRegistry(
|
||||
manifests: readonly HousekeepingDomainManifest[],
|
||||
): HousekeepingRegistry {
|
||||
const domainIds = new Set<string>();
|
||||
const routeIds = new Set<string>();
|
||||
const routeHrefs = new Set<string>();
|
||||
const routeShapes = new Set<string>();
|
||||
const searchProviderIds = new Set<string>();
|
||||
const inboxSourceIds = new Set<string>();
|
||||
const widgetIds = new Set<string>();
|
||||
const registryEntryIds = new Set<string>();
|
||||
|
||||
for (const manifest of manifests) {
|
||||
if (!approvedDomainIds.has(manifest.id)) {
|
||||
@@ -118,11 +34,8 @@ export function createHousekeepingRegistry(
|
||||
}
|
||||
domainIds.add(manifest.id);
|
||||
|
||||
if (
|
||||
manifest.canonicalHref !==
|
||||
(manifest.id === "operations" ? "/ase" : `/ase/${manifest.id}`)
|
||||
) {
|
||||
throw new Error(`invalid canonical href: ${manifest.canonicalHref}`);
|
||||
if (manifest.previewHref !== `/admin-next/${manifest.id}`) {
|
||||
throw new Error(`invalid preview href: ${manifest.previewHref}`);
|
||||
}
|
||||
validateNonEmpty(manifest.labelKey, "label key");
|
||||
validateNonEmpty(manifest.descriptionKey, "description key");
|
||||
@@ -131,19 +44,19 @@ export function createHousekeepingRegistry(
|
||||
manifest.searchProviders,
|
||||
manifest.id,
|
||||
"search provider",
|
||||
searchProviderIds,
|
||||
registryEntryIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.inboxSources,
|
||||
manifest.id,
|
||||
"inbox source",
|
||||
inboxSourceIds,
|
||||
registryEntryIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.widgets,
|
||||
manifest.id,
|
||||
"widget",
|
||||
widgetIds,
|
||||
registryEntryIds,
|
||||
);
|
||||
for (const widget of manifest.widgets) {
|
||||
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
||||
@@ -159,20 +72,11 @@ export function createHousekeepingRegistry(
|
||||
routeIds.add(route.id);
|
||||
|
||||
validateNonEmpty(route.href, "route href");
|
||||
if (route.href !== "/ase" && !route.href.startsWith("/ase/")) {
|
||||
throw new Error(`invalid canonical route href: ${route.href}`);
|
||||
}
|
||||
if (routeHrefs.has(route.href)) {
|
||||
throw new Error(`duplicate route href: ${route.href}`);
|
||||
}
|
||||
routeHrefs.add(route.href);
|
||||
|
||||
const routeShape = dynamicRouteShape(route.href);
|
||||
if (routeShape && routeShapes.has(routeShape)) {
|
||||
throw new Error(`duplicate dynamic route shape: ${routeShape}`);
|
||||
}
|
||||
if (routeShape) routeShapes.add(routeShape);
|
||||
|
||||
validateNonEmpty(route.labelKey, "route label key");
|
||||
validateCapability(route.capability);
|
||||
}
|
||||
@@ -181,20 +85,6 @@ export function createHousekeepingRegistry(
|
||||
return { domains: Object.freeze([...manifests]) };
|
||||
}
|
||||
|
||||
function dynamicRouteShape(href: string): string | null {
|
||||
let hasDynamicSegment = false;
|
||||
const shape = href
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
if (!segment.startsWith(":")) return segment;
|
||||
hasDynamicSegment = true;
|
||||
return ":";
|
||||
})
|
||||
.join("/");
|
||||
|
||||
return hasDynamicSegment ? shape : null;
|
||||
}
|
||||
|
||||
function validateOwnedRegistryEntries(
|
||||
entries: readonly OwnedRegistryEntry[],
|
||||
owner: HousekeepingDomainManifest["id"],
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CommandDeck } from "./shell/command-deck";
|
||||
import { HousekeepingShell } from "./shell/housekeeping-shell";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
skipToContent: "Skip",
|
||||
primaryNavigation: "Primary",
|
||||
contextualNavigation: "Context",
|
||||
command: "Search",
|
||||
preview: "Preview",
|
||||
backToSite: "Back",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Partial",
|
||||
close: "Close",
|
||||
},
|
||||
};
|
||||
|
||||
describe("housekeeping responsive contract", () => {
|
||||
it("stacks both navigation levels below lg without removing capabilities", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId="operations"
|
||||
domains={[
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Operations",
|
||||
items: [{ id: "workspace", href: "/ase", label: "Workspace" }],
|
||||
},
|
||||
]}
|
||||
labels={labels}
|
||||
search={vi.fn()}
|
||||
>
|
||||
<div>Body</div>
|
||||
</HousekeepingShell>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-responsive-mode="stacked-below-lg"');
|
||||
expect(html).toContain('data-navigation-collapse="domain"');
|
||||
expect(html).toContain('data-navigation-collapse="context"');
|
||||
expect(html).toContain('href="/ase/operations"');
|
||||
expect(html).toContain('href="/ase"');
|
||||
expect(html).toContain("overflow-x-auto");
|
||||
expect(html).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("keeps the command deck bounded at a 320px viewport", () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/features/housekeeping/foundation/shell/command-deck.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const trigger = renderToStaticMarkup(
|
||||
<CommandDeck
|
||||
labels={{ ...labels.commandDeck, trigger: "Search" }}
|
||||
search={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(trigger).toContain("min-w-0");
|
||||
expect(source).toContain("max-h-[calc(100dvh-2rem)]");
|
||||
expect(source).toContain("overflow-hidden");
|
||||
expect(source).toContain("p-2 sm:p-4");
|
||||
});
|
||||
|
||||
it("provides non-overflow and reduced-motion safeguards for housekeeping content", () => {
|
||||
const css = readFileSync(
|
||||
resolve(process.cwd(), "src/app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(css).toContain("[data-housekeeping-root]");
|
||||
expect(css).toContain("#housekeeping-content table");
|
||||
expect(css).toContain("overflow-x: auto");
|
||||
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
|
||||
expect(css).toContain("transition-duration: 0.01ms");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanonicalHousekeepingHref } from "../contracts";
|
||||
import { HOUSEKEEPING_ROOT, toHousekeepingHref } from "./href";
|
||||
|
||||
describe("toHousekeepingHref", () => {
|
||||
it("keeps the canonical operations root unchanged", () => {
|
||||
expect(HOUSEKEEPING_ROOT).toBe("/ase");
|
||||
expect(toHousekeepingHref("/ase")).toBe("/ase");
|
||||
});
|
||||
|
||||
it("keeps canonical domain routes unchanged", () => {
|
||||
expect(toHousekeepingHref("/ase/people/users")).toBe("/ase/people/users");
|
||||
});
|
||||
|
||||
it("rejects paths outside the canonical ase namespace", () => {
|
||||
expect(() =>
|
||||
toHousekeepingHref("/admin" as CanonicalHousekeepingHref),
|
||||
).toThrow("invalid canonical housekeeping href");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { CanonicalHousekeepingHref } from "../contracts";
|
||||
|
||||
export const HOUSEKEEPING_ROOT = "/ase" as const;
|
||||
|
||||
export function toHousekeepingHref(
|
||||
href: CanonicalHousekeepingHref,
|
||||
): CanonicalHousekeepingHref {
|
||||
if (href !== HOUSEKEEPING_ROOT && !href.startsWith(`${HOUSEKEEPING_ROOT}/`)) {
|
||||
throw new Error(`invalid canonical housekeeping href: ${href}`);
|
||||
}
|
||||
|
||||
return href;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../contracts";
|
||||
import { createHousekeepingRegistry } from "../registry";
|
||||
import { matchHousekeepingRoute } from "./match-route";
|
||||
|
||||
const requirement = anyCapability(PERMS.USERS_VIEW);
|
||||
|
||||
function peopleManifest(
|
||||
routes: HousekeepingDomainManifest["routes"],
|
||||
): HousekeepingDomainManifest {
|
||||
return {
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: requirement,
|
||||
routes,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function route(
|
||||
id: string,
|
||||
href: CanonicalHousekeepingHref,
|
||||
): HousekeepingDomainManifest["routes"][number] {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.routes.${id}`,
|
||||
href,
|
||||
capability: requirement,
|
||||
};
|
||||
}
|
||||
|
||||
const registry = createHousekeepingRegistry([
|
||||
peopleManifest([
|
||||
route("people.user-detail", "/ase/people/users/:id"),
|
||||
route("people.user-create", "/ase/people/users/new"),
|
||||
route("people.users", "/ase/people/users"),
|
||||
route("people.ticket-note", "/ase/people/tickets/:ticketId/notes/:noteId"),
|
||||
route("people.literal-tool", "/ase/people/tools/a+b[1]"),
|
||||
]),
|
||||
]);
|
||||
|
||||
describe("matchHousekeepingRoute", () => {
|
||||
it("matches a static route without parameters", () => {
|
||||
expect(matchHousekeepingRoute(registry, "/ase/people/users")).toEqual({
|
||||
routeId: "people.users",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/users",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a static route over a dynamic route with the same depth", () => {
|
||||
expect(matchHousekeepingRoute(registry, "/ase/people/users/new")).toEqual({
|
||||
routeId: "people.user-create",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/users/new",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches a dynamic segment and decodes its parameter", () => {
|
||||
expect(
|
||||
matchHousekeepingRoute(registry, "/ase/people/users/Ada%20Lovelace"),
|
||||
).toEqual({
|
||||
routeId: "people.user-detail",
|
||||
domain: "people",
|
||||
params: { id: "Ada Lovelace" },
|
||||
canonicalHref: "/ase/people/users/Ada%20Lovelace",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches nested dynamic segments independently", () => {
|
||||
expect(
|
||||
matchHousekeepingRoute(
|
||||
registry,
|
||||
"/ase/people/tickets/42/notes/follow-up",
|
||||
),
|
||||
).toEqual({
|
||||
routeId: "people.ticket-note",
|
||||
domain: "people",
|
||||
params: { ticketId: "42", noteId: "follow-up" },
|
||||
canonicalHref: "/ase/people/tickets/42/notes/follow-up",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats regular-expression metacharacters as literal segment text", () => {
|
||||
expect(
|
||||
matchHousekeepingRoute(registry, "/ase/people/tools/a+b[1]"),
|
||||
)?.toMatchObject({ routeId: "people.literal-tool" });
|
||||
expect(
|
||||
matchHousekeepingRoute(registry, "/ase/people/tools/ab1"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
routes: [
|
||||
route("people.kind-settings", "/ase/people/:kind/settings"),
|
||||
route("people.user-detail", "/ase/people/users/:id"),
|
||||
],
|
||||
},
|
||||
{
|
||||
routes: [
|
||||
route("people.user-detail", "/ase/people/users/:id"),
|
||||
route("people.kind-settings", "/ase/people/:kind/settings"),
|
||||
],
|
||||
},
|
||||
])(
|
||||
"prefers the earliest static segment regardless of registration order",
|
||||
({ routes }) => {
|
||||
const orderedRegistry = createHousekeepingRegistry([
|
||||
peopleManifest(routes),
|
||||
]);
|
||||
|
||||
expect(
|
||||
matchHousekeepingRoute(orderedRegistry, "/ase/people/users/settings"),
|
||||
)?.toMatchObject({
|
||||
routeId: "people.user-detail",
|
||||
params: { id: "settings" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"/ase/people/unknown",
|
||||
"/ase/economy/users",
|
||||
"/ase/system/users/Ada",
|
||||
])("returns null for unknown or cross-domain path %s", (path) => {
|
||||
expect(matchHousekeepingRoute(registry, path)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ase/people/users",
|
||||
"/ase//people/users",
|
||||
"/ase/people/users/",
|
||||
"/ase/people/users?tab=active",
|
||||
"/ase/people/users/%",
|
||||
"/ase/people/users/%2Fsystem",
|
||||
"/ase/people/users/..",
|
||||
"/ase\\people\\users",
|
||||
])("fails closed for malformed canonical path %s", (path) => {
|
||||
expect(matchHousekeepingRoute(registry, path)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { CanonicalHousekeepingHref } from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
|
||||
export interface HousekeepingRouteMatch {
|
||||
routeId: string;
|
||||
domain: HousekeepingDomainId;
|
||||
params: Readonly<Record<string, string>>;
|
||||
canonicalHref: CanonicalHousekeepingHref;
|
||||
}
|
||||
|
||||
interface ParsedCanonicalPath {
|
||||
segments: readonly string[];
|
||||
}
|
||||
|
||||
interface RoutePatternSegment {
|
||||
literal: string | null;
|
||||
parameterName: string | null;
|
||||
}
|
||||
|
||||
interface RouteCandidate {
|
||||
routeId: string;
|
||||
domain: HousekeepingDomainId;
|
||||
patternSegments: readonly RoutePatternSegment[];
|
||||
}
|
||||
|
||||
export function matchHousekeepingRoute(
|
||||
registry: HousekeepingRegistry,
|
||||
canonicalPath: string,
|
||||
): HousekeepingRouteMatch | null {
|
||||
const path = parseCanonicalPath(canonicalPath);
|
||||
if (!path) return null;
|
||||
|
||||
const candidates = registry.domains
|
||||
.flatMap((domain) =>
|
||||
domain.routes.flatMap((route) => {
|
||||
if (!routeBelongsToDomain(route.href, domain.canonicalHref)) return [];
|
||||
|
||||
const patternSegments = parseRoutePattern(route.href);
|
||||
return patternSegments
|
||||
? [{ routeId: route.id, domain: domain.id, patternSegments }]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
.sort(compareSpecificity);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const params = matchSegments(candidate, path);
|
||||
if (!params) continue;
|
||||
|
||||
return {
|
||||
routeId: candidate.routeId,
|
||||
domain: candidate.domain,
|
||||
params: Object.freeze(params),
|
||||
canonicalHref: canonicalPath as CanonicalHousekeepingHref,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCanonicalPath(canonicalPath: string): ParsedCanonicalPath | null {
|
||||
if (
|
||||
canonicalPath !== canonicalPath.trim() ||
|
||||
!canonicalPath.startsWith("/ase") ||
|
||||
canonicalPath.includes("?") ||
|
||||
canonicalPath.includes("#") ||
|
||||
canonicalPath.includes("\\") ||
|
||||
canonicalPath.endsWith("/")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSegments = canonicalPath.slice(1).split("/");
|
||||
if (rawSegments[0] !== "ase" || rawSegments.some((segment) => !segment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const segment of rawSegments) {
|
||||
const decoded = decodeCanonicalSegment(segment);
|
||||
if (decoded === null) return null;
|
||||
segments.push(decoded);
|
||||
}
|
||||
|
||||
return { segments };
|
||||
}
|
||||
|
||||
function parseRoutePattern(
|
||||
routeHref: CanonicalHousekeepingHref,
|
||||
): readonly RoutePatternSegment[] | null {
|
||||
const segments: RoutePatternSegment[] = [];
|
||||
|
||||
for (const segment of routeHref.slice(1).split("/")) {
|
||||
if (segment.startsWith(":")) {
|
||||
const parameterName = segment.slice(1);
|
||||
if (!parameterName) return null;
|
||||
segments.push({ literal: null, parameterName });
|
||||
continue;
|
||||
}
|
||||
|
||||
const literal = decodeCanonicalSegment(segment);
|
||||
if (literal === null) return null;
|
||||
segments.push({ literal, parameterName: null });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function decodeCanonicalSegment(segment: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return !decoded ||
|
||||
decoded === "." ||
|
||||
decoded === ".." ||
|
||||
decoded.includes("/") ||
|
||||
decoded.includes("\\")
|
||||
? null
|
||||
: decoded;
|
||||
}
|
||||
|
||||
function compareSpecificity(
|
||||
left: RouteCandidate,
|
||||
right: RouteCandidate,
|
||||
): number {
|
||||
const sharedLength = Math.min(
|
||||
left.patternSegments.length,
|
||||
right.patternSegments.length,
|
||||
);
|
||||
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
const leftDynamic = left.patternSegments[index]?.parameterName !== null;
|
||||
const rightDynamic = right.patternSegments[index]?.parameterName !== null;
|
||||
if (leftDynamic === rightDynamic) continue;
|
||||
return leftDynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
return left.patternSegments.length - right.patternSegments.length;
|
||||
}
|
||||
|
||||
function routeBelongsToDomain(
|
||||
routeHref: CanonicalHousekeepingHref,
|
||||
domainHref: CanonicalHousekeepingHref,
|
||||
): boolean {
|
||||
return (
|
||||
routeHref === domainHref ||
|
||||
(domainHref !== "/ase" && routeHref.startsWith(`${domainHref}/`)) ||
|
||||
(domainHref === "/ase" && routeHref === "/ase")
|
||||
);
|
||||
}
|
||||
|
||||
function matchSegments(
|
||||
candidate: RouteCandidate,
|
||||
path: ParsedCanonicalPath,
|
||||
): Record<string, string> | null {
|
||||
if (candidate.patternSegments.length !== path.segments.length) return null;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [index, patternSegment] of candidate.patternSegments.entries()) {
|
||||
const pathSegment = path.segments[index] ?? "";
|
||||
if (patternSegment.parameterName === null) {
|
||||
if (patternSegment.literal !== pathSegment) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternSegment.parameterName in params) return null;
|
||||
params[patternSegment.parameterName] = pathSegment;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../commands/registry";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingDomainManifest,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import { searchNavigationAndCommands } from "./navigation-search";
|
||||
|
||||
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 peopleManifest = {
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: { mode: "any", slugs: [PERMS.USERS_VIEW] },
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
href: "/ase/people/users",
|
||||
labelKey: "pages.housekeeping.routes.people.users",
|
||||
capability: { mode: "any", slugs: [PERMS.USERS_VIEW] },
|
||||
},
|
||||
{
|
||||
id: "people.moderation",
|
||||
href: "/ase/people/moderation",
|
||||
labelKey: "pages.housekeeping.routes.people.moderation",
|
||||
capability: { mode: "any", slugs: [PERMS.MODERATION_VIEW] },
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} as const satisfies HousekeepingDomainManifest;
|
||||
|
||||
const registry: HousekeepingRegistry = { domains: [peopleManifest] };
|
||||
|
||||
function command(
|
||||
id: string,
|
||||
permission: string,
|
||||
): HousekeepingCommand<Record<string, never>, never> {
|
||||
return {
|
||||
id,
|
||||
owner: "people",
|
||||
risk: "safe",
|
||||
capability: { mode: "any", slugs: [permission] },
|
||||
input: z.object({}),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 3, windowMs: 60_000 },
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
data: undefined as never,
|
||||
correlationId: "command",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"pages.housekeeping.domains.people.title": "People",
|
||||
"pages.housekeeping.domains.people.description": "Accounts and moderation",
|
||||
"pages.housekeeping.routes.people.users": "User accounts",
|
||||
"pages.housekeeping.routes.people.moderation": "Moderation queue",
|
||||
};
|
||||
|
||||
describe("navigation and command search", () => {
|
||||
it("matches permitted domain/routes/commands and omits forbidden entries", () => {
|
||||
const result = searchNavigationAndCommands({
|
||||
registry,
|
||||
commands: [
|
||||
command("people.user.refresh", PERMS.USERS_VIEW),
|
||||
command("people.moderation.ban", PERMS.MODERATION_VIEW),
|
||||
],
|
||||
context: context([PERMS.USERS_VIEW]),
|
||||
term: "user",
|
||||
translate: (key) => translations[key] ?? key,
|
||||
});
|
||||
|
||||
expect(result.navigation.map((hit) => hit.id)).toEqual([
|
||||
"route:people.users",
|
||||
]);
|
||||
expect(result.commands.map((hit) => hit.commandId)).toEqual([
|
||||
"people.user.refresh",
|
||||
]);
|
||||
expect(result.navigation[0]?.href).toBe("/ase/people/users");
|
||||
expect(result.commands[0]?.href).toBe("/ase/people");
|
||||
});
|
||||
|
||||
it("normalizes terms and keeps registry/command order stable", () => {
|
||||
const result = searchNavigationAndCommands({
|
||||
registry,
|
||||
commands: [
|
||||
command("people.user.refresh", PERMS.USERS_VIEW),
|
||||
command("people.user.inspect", PERMS.USERS_VIEW),
|
||||
],
|
||||
context: context([PERMS.USERS_VIEW]),
|
||||
term: " PEOPLE ",
|
||||
translate: (key) => translations[key] ?? key,
|
||||
});
|
||||
|
||||
expect(result.navigation.map((hit) => hit.id)).toEqual(["domain:people"]);
|
||||
expect(result.commands.map((hit) => hit.commandId)).toEqual([
|
||||
"people.user.refresh",
|
||||
"people.user.inspect",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import type {
|
||||
CanonicalHousekeepingHref,
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingSearchResult,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
|
||||
export interface HousekeepingNavigationHit
|
||||
extends Omit<HousekeepingSearchResult, "type"> {
|
||||
readonly type: "navigation";
|
||||
}
|
||||
|
||||
export interface HousekeepingCommandHit
|
||||
extends Omit<HousekeepingSearchResult, "type"> {
|
||||
readonly type: "command";
|
||||
readonly commandId: string;
|
||||
readonly risk: "safe" | "sensitive";
|
||||
readonly requiresReason: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoverableHousekeepingCommand {
|
||||
readonly id: string;
|
||||
readonly owner: HousekeepingCommandHit["domain"];
|
||||
readonly risk: HousekeepingCommandHit["risk"];
|
||||
readonly capability: CapabilityRequirement;
|
||||
readonly requiresReason: boolean;
|
||||
}
|
||||
|
||||
export interface NavigationAndCommandSearchInput {
|
||||
readonly registry: HousekeepingRegistry;
|
||||
readonly commands: readonly DiscoverableHousekeepingCommand[];
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly term: string;
|
||||
readonly translate: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface NavigationAndCommandSearchResult {
|
||||
readonly navigation: readonly HousekeepingNavigationHit[];
|
||||
readonly commands: readonly HousekeepingCommandHit[];
|
||||
}
|
||||
|
||||
export function normalizeHousekeepingSearchTerm(term: string): string {
|
||||
return term.normalize("NFC").trim().slice(0, 128);
|
||||
}
|
||||
|
||||
function searchTokens(term: string): readonly string[] {
|
||||
return normalizeHousekeepingSearchTerm(term)
|
||||
.toLocaleLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function matches(tokens: readonly string[], ...values: readonly string[]) {
|
||||
if (tokens.length === 0) return true;
|
||||
const haystack = values.join(" ").normalize("NFC").toLocaleLowerCase();
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
function commandTitle(
|
||||
commandId: string,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
const key = `pages.housekeeping.commands.${commandId}`;
|
||||
const translated = translate(key);
|
||||
if (translated !== key) return translated;
|
||||
return commandId
|
||||
.split(".")
|
||||
.slice(1)
|
||||
.flatMap((segment) => segment.split("-"))
|
||||
.map((segment) =>
|
||||
segment.length === 0
|
||||
? segment
|
||||
: `${segment[0]?.toLocaleUpperCase()}${segment.slice(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function searchNavigationAndCommands({
|
||||
registry,
|
||||
commands,
|
||||
context,
|
||||
term,
|
||||
translate,
|
||||
}: NavigationAndCommandSearchInput): NavigationAndCommandSearchResult {
|
||||
const tokens = searchTokens(term);
|
||||
const navigation: HousekeepingNavigationHit[] = [];
|
||||
|
||||
for (const domain of registry.domains) {
|
||||
if (!satisfiesCapability(context, domain.capability)) continue;
|
||||
const domainTitle = translate(domain.labelKey);
|
||||
const domainDescription = translate(domain.descriptionKey);
|
||||
if (matches(tokens, domain.id, domainTitle, domainDescription)) {
|
||||
navigation.push({
|
||||
id: `domain:${domain.id}`,
|
||||
domain: domain.id,
|
||||
type: "navigation",
|
||||
title: domainTitle,
|
||||
description: domainDescription,
|
||||
href: domain.canonicalHref,
|
||||
capability: domain.capability,
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of domain.routes) {
|
||||
if (!satisfiesCapability(context, route.capability)) continue;
|
||||
const title = translate(route.labelKey);
|
||||
const localRouteId = route.id.split(".").slice(1).join(" ");
|
||||
if (!matches(tokens, localRouteId, title)) continue;
|
||||
navigation.push({
|
||||
id: `route:${route.id}`,
|
||||
domain: domain.id,
|
||||
type: "navigation",
|
||||
title,
|
||||
href: route.href,
|
||||
capability: route.capability,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const commandsFound: HousekeepingCommandHit[] = [];
|
||||
for (const command of commands) {
|
||||
if (!satisfiesCapability(context, command.capability)) continue;
|
||||
const domain = registry.domains.find(
|
||||
(candidate) => candidate.id === command.owner,
|
||||
);
|
||||
if (!domain || !satisfiesCapability(context, domain.capability)) continue;
|
||||
const title = commandTitle(command.id, translate);
|
||||
if (!matches(tokens, command.id, command.owner, title)) continue;
|
||||
commandsFound.push({
|
||||
id: `command:${command.id}`,
|
||||
commandId: command.id,
|
||||
domain: command.owner,
|
||||
type: "command",
|
||||
title,
|
||||
description: command.id,
|
||||
href: domain.canonicalHref as CanonicalHousekeepingHref,
|
||||
capability: command.capability,
|
||||
risk: command.risk,
|
||||
requiresReason: command.requiresReason,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
navigation: navigation.slice(0, 50),
|
||||
commands: commandsFound.slice(0, 50),
|
||||
};
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingSearchProvider,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import {
|
||||
createHousekeepingSearchService,
|
||||
SEARCH_POLICY,
|
||||
} from "./search-service";
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
function provider(
|
||||
id: string,
|
||||
permission: string,
|
||||
search: HousekeepingSearchProvider["search"],
|
||||
): HousekeepingSearchProvider {
|
||||
return {
|
||||
id,
|
||||
owner: "people",
|
||||
capability: anyCapability(permission),
|
||||
search,
|
||||
};
|
||||
}
|
||||
|
||||
function registry(
|
||||
searchProviders: readonly HousekeepingSearchProvider[],
|
||||
): HousekeepingRegistry {
|
||||
return {
|
||||
domains: [
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
href: "/ase/people/users",
|
||||
labelKey: "pages.housekeeping.routes.people.users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
searchProviders,
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function entity(id: string) {
|
||||
return {
|
||||
id,
|
||||
domain: "people" as const,
|
||||
type: "entity" as const,
|
||||
title: `User ${id}`,
|
||||
href: "/ase/people/users" as const,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Housekeeping search service", () => {
|
||||
it("locks the provider timeout and result limits", () => {
|
||||
expect(SEARCH_POLICY).toEqual({
|
||||
minimumEntityTermLength: 2,
|
||||
timeoutMs: 2_000,
|
||||
providerLimit: 25,
|
||||
combinedEntityLimit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("searches navigation/commands only below two trimmed characters", async () => {
|
||||
const search = vi.fn(async () => ok([entity("1")], "provider"));
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([provider("people.users", PERMS.USERS_VIEW, search)]),
|
||||
commands: [],
|
||||
translate: (key) =>
|
||||
key.endsWith("people.users") ? "Accounts" : "People",
|
||||
createCorrelationId: () => "search-short",
|
||||
});
|
||||
|
||||
const response = await service(" a ", context([PERMS.USERS_VIEW]));
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(response.navigation.map((hit) => hit.title)).toContain("Accounts");
|
||||
expect(response.entities).toEqual([]);
|
||||
expect(response.correlationId).toBe("search-short");
|
||||
});
|
||||
|
||||
it("invokes only permitted providers for two-character terms", async () => {
|
||||
const permitted = vi.fn(async () => ok([entity("1")], "permitted"));
|
||||
const forbidden = vi.fn(async () => ok([entity("2")], "forbidden"));
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([
|
||||
provider("people.users", PERMS.USERS_VIEW, permitted),
|
||||
provider("people.bans", PERMS.BANS_VIEW, forbidden),
|
||||
]),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-two",
|
||||
});
|
||||
|
||||
const response = await service("ab", context([PERMS.USERS_VIEW]));
|
||||
expect(permitted).toHaveBeenCalledWith(expect.any(Object), {
|
||||
term: "ab",
|
||||
limit: 25,
|
||||
});
|
||||
expect(forbidden).not.toHaveBeenCalled();
|
||||
expect(response.entities.map((item) => item.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("does not invoke providers owned by a forbidden domain", async () => {
|
||||
const search = vi.fn(async () => ok([entity("1")], "forbidden-domain"));
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([provider("people.bans", PERMS.BANS_VIEW, search)]),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-forbidden-domain",
|
||||
});
|
||||
|
||||
const response = await service("ab", context([PERMS.BANS_VIEW]));
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(response.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps providers at 25, deduplicates stably, and caps combined entities at 50", async () => {
|
||||
const ranges = [
|
||||
Array.from({ length: 30 }, (_, index) => entity(String(index))),
|
||||
Array.from({ length: 30 }, (_, index) => entity(String(index + 20))),
|
||||
Array.from({ length: 30 }, (_, index) => entity(String(index + 45))),
|
||||
];
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry(
|
||||
ranges.map((items, index) =>
|
||||
provider(`people.${index}`, PERMS.USERS_VIEW, async () =>
|
||||
ok(items, `provider-${index}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-limits",
|
||||
});
|
||||
|
||||
const response = await service("user", context([PERMS.USERS_VIEW]));
|
||||
expect(response.entities).toHaveLength(50);
|
||||
expect(response.entities.map((item) => item.id)).toEqual(
|
||||
Array.from({ length: 50 }, (_, index) => String(index)),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns successful entities with stable typed timeout/provider errors", async () => {
|
||||
const never = new Promise<never>(() => undefined);
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([
|
||||
provider("people.timeout", PERMS.USERS_VIEW, () => never),
|
||||
provider("people.failed", PERMS.USERS_VIEW, async () =>
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"failed",
|
||||
),
|
||||
),
|
||||
provider("people.ready", PERMS.USERS_VIEW, async () =>
|
||||
ok([entity("ready")], "ready"),
|
||||
),
|
||||
]),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-partial",
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
const response = await service("user", context([PERMS.USERS_VIEW]));
|
||||
expect(response.entities.map((item) => item.id)).toEqual(["ready"]);
|
||||
expect(response.errors).toEqual([
|
||||
{ providerId: "people.timeout", code: "TIMEOUT" },
|
||||
{ providerId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import { listHousekeepingCommands } from "../commands/registry";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingErrorCode,
|
||||
HousekeepingSearchProvider,
|
||||
HousekeepingSearchResult,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import { isSafeHousekeepingHref } from "../housekeeping-href";
|
||||
import {
|
||||
createHousekeepingRegistry,
|
||||
type HousekeepingRegistry,
|
||||
} from "../registry";
|
||||
import {
|
||||
type DiscoverableHousekeepingCommand,
|
||||
type HousekeepingCommandHit,
|
||||
type HousekeepingNavigationHit,
|
||||
normalizeHousekeepingSearchTerm,
|
||||
searchNavigationAndCommands,
|
||||
} from "./navigation-search";
|
||||
|
||||
export const SEARCH_POLICY = Object.freeze({
|
||||
minimumEntityTermLength: 2,
|
||||
timeoutMs: 2_000,
|
||||
providerLimit: 25,
|
||||
combinedEntityLimit: 50,
|
||||
} as const);
|
||||
|
||||
export interface HousekeepingSearchResponse {
|
||||
readonly navigation: readonly HousekeepingNavigationHit[];
|
||||
readonly commands: readonly HousekeepingCommandHit[];
|
||||
readonly entities: readonly HousekeepingSearchResult[];
|
||||
readonly errors: readonly {
|
||||
providerId: string;
|
||||
code: HousekeepingErrorCode;
|
||||
}[];
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingSearchServiceDependencies {
|
||||
readonly registry: HousekeepingRegistry;
|
||||
readonly commands: readonly DiscoverableHousekeepingCommand[];
|
||||
readonly translate: (key: string) => string;
|
||||
readonly createCorrelationId?: () => string;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
type ProviderOutcome = {
|
||||
readonly providerId: string;
|
||||
readonly items: readonly HousekeepingSearchResult[];
|
||||
readonly error?: HousekeepingErrorCode;
|
||||
};
|
||||
|
||||
function validEntityResult(
|
||||
item: HousekeepingSearchResult,
|
||||
provider: HousekeepingSearchProvider,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): boolean {
|
||||
return (
|
||||
item !== null &&
|
||||
typeof item === "object" &&
|
||||
item.type === "entity" &&
|
||||
item.domain === provider.owner &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.trim().length > 0 &&
|
||||
typeof item.title === "string" &&
|
||||
item.title.trim().length > 0 &&
|
||||
typeof item.href === "string" &&
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability)
|
||||
);
|
||||
}
|
||||
|
||||
async function runProvider(
|
||||
provider: HousekeepingSearchProvider,
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
timeoutMs: number,
|
||||
): Promise<ProviderOutcome> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = new Promise<ProviderOutcome>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
providerId: provider.id,
|
||||
items: [],
|
||||
error: "TIMEOUT",
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
const searched = provider
|
||||
.search(context, { term, limit: SEARCH_POLICY.providerLimit })
|
||||
.then<ProviderOutcome>((result) => {
|
||||
if (!result.ok) {
|
||||
return {
|
||||
providerId: provider.id,
|
||||
items: [],
|
||||
error: result.error.code,
|
||||
};
|
||||
}
|
||||
return {
|
||||
providerId: provider.id,
|
||||
items: result.data
|
||||
.filter((item) => validEntityResult(item, provider, context))
|
||||
.slice(0, SEARCH_POLICY.providerLimit),
|
||||
};
|
||||
})
|
||||
.catch<ProviderOutcome>(() => ({
|
||||
providerId: provider.id,
|
||||
items: [],
|
||||
error: "INTERNAL",
|
||||
}));
|
||||
|
||||
try {
|
||||
return await Promise.race([searched, timedOut]);
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHousekeepingSearchService({
|
||||
registry,
|
||||
commands,
|
||||
translate,
|
||||
createCorrelationId: correlationIdFactory = createCorrelationId,
|
||||
timeoutMs = SEARCH_POLICY.timeoutMs,
|
||||
}: HousekeepingSearchServiceDependencies) {
|
||||
return async function runSearch(
|
||||
term: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingSearchResponse> {
|
||||
const normalizedTerm = normalizeHousekeepingSearchTerm(term);
|
||||
const navigationAndCommands = searchNavigationAndCommands({
|
||||
registry,
|
||||
commands,
|
||||
context,
|
||||
term: normalizedTerm,
|
||||
translate,
|
||||
});
|
||||
if (normalizedTerm.length < SEARCH_POLICY.minimumEntityTermLength) {
|
||||
return {
|
||||
...navigationAndCommands,
|
||||
entities: [],
|
||||
errors: [],
|
||||
correlationId: correlationIdFactory(),
|
||||
};
|
||||
}
|
||||
|
||||
const providers = registry.domains.flatMap((domain) => {
|
||||
if (!satisfiesCapability(context, domain.capability)) return [];
|
||||
return domain.searchProviders.filter((provider) =>
|
||||
satisfiesCapability(context, provider.capability),
|
||||
);
|
||||
});
|
||||
const outcomes = await Promise.all(
|
||||
providers.map((provider) =>
|
||||
runProvider(provider, context, normalizedTerm, timeoutMs),
|
||||
),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const entities: HousekeepingSearchResult[] = [];
|
||||
for (const outcome of outcomes) {
|
||||
for (const item of outcome.items) {
|
||||
const key = `${item.domain}:${item.type}:${item.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entities.push(item);
|
||||
if (entities.length === SEARCH_POLICY.combinedEntityLimit) break;
|
||||
}
|
||||
if (entities.length === SEARCH_POLICY.combinedEntityLimit) break;
|
||||
}
|
||||
|
||||
return {
|
||||
...navigationAndCommands,
|
||||
entities,
|
||||
errors: outcomes.flatMap((outcome) =>
|
||||
outcome.error
|
||||
? [{ providerId: outcome.providerId, code: outcome.error }]
|
||||
: [],
|
||||
),
|
||||
correlationId: correlationIdFactory(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const MESSAGE_PREFIX = "pages.housekeeping.";
|
||||
|
||||
export async function searchHousekeeping(
|
||||
term: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingSearchResponse> {
|
||||
const [{ getTranslations }, { HOUSEKEEPING_MANIFESTS }] = await Promise.all([
|
||||
import("next-intl/server"),
|
||||
import("../../manifests"),
|
||||
]);
|
||||
const translate = await getTranslations("pages.housekeeping");
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||
commands: listHousekeepingCommands(),
|
||||
translate: (key) => {
|
||||
if (!key.startsWith(MESSAGE_PREFIX)) return key;
|
||||
return translate(key.slice(MESSAGE_PREFIX.length) as never);
|
||||
},
|
||||
});
|
||||
return service(term, context);
|
||||
}
|
||||
@@ -1,138 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
auth,
|
||||
getAdminContext,
|
||||
getTranslations,
|
||||
notFound,
|
||||
redirect,
|
||||
startLogicalRequest,
|
||||
staleActor,
|
||||
wrapCache,
|
||||
} = vi.hoisted(() => {
|
||||
let requestCache = new Map<symbol, Promise<unknown>>();
|
||||
|
||||
return {
|
||||
auth: vi.fn(),
|
||||
getAdminContext: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
notFound: vi.fn((): never => {
|
||||
throw new Error("NEXT_NOT_FOUND");
|
||||
}),
|
||||
redirect: vi.fn((href: string): never => {
|
||||
throw new Error(`NEXT_REDIRECT:${href}`);
|
||||
}),
|
||||
startLogicalRequest: () => {
|
||||
requestCache = new Map();
|
||||
},
|
||||
staleActor: { id: 9, username: "stale-session", rank: 1 },
|
||||
wrapCache: (callback: () => Promise<unknown>) => {
|
||||
const cacheKey = Symbol("housekeeping-capability-context");
|
||||
return () => {
|
||||
let value = requestCache.get(cacheKey);
|
||||
if (!value) {
|
||||
value = callback();
|
||||
requestCache.set(cacheKey, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react")>()),
|
||||
cache: wrapCache,
|
||||
const { auth, getAdminContext, staleActor } = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
getAdminContext: vi.fn(),
|
||||
staleActor: { id: 9, username: "stale-session", rank: 1 },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ notFound, redirect }));
|
||||
vi.mock("next-intl/server", () => ({ getTranslations }));
|
||||
|
||||
vi.mock("react", () => ({
|
||||
cache: <T extends (...args: never[]) => unknown>(callback: T) => callback,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ auth }));
|
||||
vi.mock("@/lib/db", () => {
|
||||
throw new Error("server capability context must not access the database");
|
||||
});
|
||||
vi.mock("@/lib/permissions", () => ({ getAdminContext }));
|
||||
|
||||
import AdminNextDomainLayout from "@/app/ase/[domain]/layout";
|
||||
import AdminNextPage from "@/app/ase/page";
|
||||
import { requireHousekeepingCapability } from "@/lib/admin/guard";
|
||||
import { auth as mockedAuth } from "@/lib/auth";
|
||||
import { anyCapability } from "./contracts";
|
||||
import { getHousekeepingCapabilityContext } from "./server-capability-context";
|
||||
|
||||
function adminContext(
|
||||
actor: { id: number; username: string; rank: number },
|
||||
granted: readonly string[],
|
||||
) {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
session: { user: actor },
|
||||
permissions: {
|
||||
isSuperAdmin: false,
|
||||
has: (slug: string) => permissions.has(slug),
|
||||
hasAny: (...slugs: string[]) =>
|
||||
slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs: string[]) =>
|
||||
slugs.every((slug) => permissions.has(slug)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeRequestConsumers() {
|
||||
const shell = await AdminNextDomainLayout({
|
||||
children: null,
|
||||
params: Promise.resolve({ domain: "operations" }),
|
||||
});
|
||||
const page = await AdminNextPage();
|
||||
const commandContext = await requireHousekeepingCapability(
|
||||
anyCapability("admin.dashboard"),
|
||||
);
|
||||
|
||||
return { commandContext, page, shell };
|
||||
}
|
||||
|
||||
describe("getHousekeepingCapabilityContext", () => {
|
||||
beforeEach(() => {
|
||||
startLogicalRequest();
|
||||
vi.clearAllMocks();
|
||||
auth.mockResolvedValue({ user: staleActor });
|
||||
getTranslations.mockResolvedValue((key: string) => key);
|
||||
getAdminContext.mockResolvedValue(
|
||||
adminContext({ id: 42, username: "operator", rank: 7 }, [
|
||||
"admin.dashboard",
|
||||
"admin.users.view",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("isolates real shell, page, and command preflight consumers between logical requests", async () => {
|
||||
const first = await invokeRequestConsumers();
|
||||
|
||||
expect(first.shell).toBeDefined();
|
||||
expect(first.page).toBeDefined();
|
||||
expect(first.commandContext.actor).toEqual({
|
||||
id: 42,
|
||||
username: "operator",
|
||||
rank: 7,
|
||||
getAdminContext.mockResolvedValue({
|
||||
session: {
|
||||
user: {
|
||||
id: 42,
|
||||
username: "operator",
|
||||
rank: 7,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
isSuperAdmin: false,
|
||||
has: (slug: string) => slug === "admin.users.view",
|
||||
hasAny: (...slugs: string[]) => slugs.includes("admin.users.view"),
|
||||
hasAll: (...slugs: string[]) =>
|
||||
slugs.every((slug) => slug === "admin.users.view"),
|
||||
},
|
||||
});
|
||||
expect(getAdminContext).toHaveBeenCalledTimes(1);
|
||||
|
||||
startLogicalRequest();
|
||||
getAdminContext.mockResolvedValue(
|
||||
adminContext({ id: 84, username: "second-operator", rank: 2 }, [
|
||||
"admin.dashboard",
|
||||
]),
|
||||
);
|
||||
const second = await invokeRequestConsumers();
|
||||
|
||||
expect(second.shell).toBeDefined();
|
||||
expect(second.page).toBeDefined();
|
||||
expect(second.commandContext.actor).toEqual({
|
||||
id: 84,
|
||||
username: "second-operator",
|
||||
rank: 2,
|
||||
});
|
||||
expect(second.commandContext.has("admin.users.view")).toBe(false);
|
||||
expect(getAdminContext).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the refreshed administrator actor instead of the stale auth actor", async () => {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CommandDeck,
|
||||
commandDeckKeyAction,
|
||||
flattenHousekeepingSearchResults,
|
||||
isCommandDeckShortcut,
|
||||
nextCommandDeckIndex,
|
||||
projectHousekeepingSearchHref,
|
||||
restoreCommandDeckFocus,
|
||||
} from "./command-deck";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
trigger: "Search housekeeping",
|
||||
placeholder: "Search navigation, commands, and entities",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Searching…",
|
||||
empty: "No results",
|
||||
partial: "Some providers are unavailable",
|
||||
close: "Close",
|
||||
};
|
||||
|
||||
describe("CommandDeck", () => {
|
||||
it("renders an enabled accessible cmdk trigger", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CommandDeck labels={labels} search={vi.fn()} />,
|
||||
);
|
||||
expect(html).toContain("Search housekeeping");
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
expect(html).not.toContain("disabled");
|
||||
expect(html).toContain("Ctrl+K");
|
||||
});
|
||||
|
||||
it("models shortcut, arrows, Enter, and Escape deterministically", () => {
|
||||
expect(
|
||||
isCommandDeckShortcut({ key: "k", ctrlKey: true, metaKey: false }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCommandDeckShortcut({ key: "K", ctrlKey: false, metaKey: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCommandDeckShortcut({
|
||||
key: "k",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(commandDeckKeyAction("Escape", true)).toBe("close");
|
||||
expect(commandDeckKeyAction("ArrowDown", true)).toBe("next");
|
||||
expect(commandDeckKeyAction("ArrowUp", true)).toBe("previous");
|
||||
expect(commandDeckKeyAction("Enter", true)).toBe("activate");
|
||||
expect(commandDeckKeyAction("Enter", false)).toBe(null);
|
||||
expect(nextCommandDeckIndex(-1, 3, "next")).toBe(0);
|
||||
expect(nextCommandDeckIndex(2, 3, "next")).toBe(0);
|
||||
expect(nextCommandDeckIndex(0, 3, "previous")).toBe(2);
|
||||
});
|
||||
|
||||
it("restores trigger focus and keeps canonical result hrefs", () => {
|
||||
const focus = vi.fn();
|
||||
restoreCommandDeckFocus({ focus });
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(projectHousekeepingSearchHref("/ase/people/users")).toBe(
|
||||
"/ase/people/users",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps grouped result order and uses cmdk input/items in the client module", () => {
|
||||
const flattened = flattenHousekeepingSearchResults({
|
||||
navigation: [
|
||||
{
|
||||
id: "route:people.users",
|
||||
domain: "people",
|
||||
type: "navigation",
|
||||
title: "Users",
|
||||
href: "/ase/people/users",
|
||||
capability: { mode: "any", slugs: ["users.view"] },
|
||||
},
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
id: "command:people.user.refresh",
|
||||
commandId: "people.user.refresh",
|
||||
domain: "people",
|
||||
type: "command",
|
||||
title: "User Refresh",
|
||||
href: "/ase/people",
|
||||
capability: { mode: "any", slugs: ["users.view"] },
|
||||
risk: "safe",
|
||||
requiresReason: false,
|
||||
},
|
||||
],
|
||||
entities: [],
|
||||
errors: [],
|
||||
correlationId: "deck",
|
||||
});
|
||||
expect(flattened.map((entry) => entry.group)).toEqual([
|
||||
"navigation",
|
||||
"commands",
|
||||
]);
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/features/housekeeping/foundation/shell/command-deck.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("CommandInput");
|
||||
expect(source).toContain("CommandItem");
|
||||
expect(source).toContain("onSelect");
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import type { HousekeepingSearchResult } from "../contracts";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
import type {
|
||||
HousekeepingCommandHit,
|
||||
HousekeepingNavigationHit,
|
||||
} from "../search/navigation-search";
|
||||
import type { HousekeepingSearchResponse } from "../search/search-service";
|
||||
|
||||
export interface CommandDeckLabels {
|
||||
readonly trigger: string;
|
||||
readonly placeholder: string;
|
||||
readonly navigation: string;
|
||||
readonly commands: string;
|
||||
readonly entities: string;
|
||||
readonly loading: string;
|
||||
readonly empty: string;
|
||||
readonly partial: string;
|
||||
readonly close: string;
|
||||
}
|
||||
|
||||
export type HousekeepingSearchExecutor = (
|
||||
term: string,
|
||||
) => Promise<HousekeepingSearchResponse>;
|
||||
|
||||
type CommandDeckHit =
|
||||
| HousekeepingNavigationHit
|
||||
| HousekeepingCommandHit
|
||||
| HousekeepingSearchResult;
|
||||
type CommandDeckGroup = "navigation" | "commands" | "entities";
|
||||
export type CommandDeckKeyboardAction =
|
||||
| "close"
|
||||
| "next"
|
||||
| "previous"
|
||||
| "activate"
|
||||
| null;
|
||||
|
||||
export interface CommandDeckEntry {
|
||||
readonly group: CommandDeckGroup;
|
||||
readonly value: string;
|
||||
readonly hit: CommandDeckHit;
|
||||
}
|
||||
|
||||
const EMPTY_RESPONSE: HousekeepingSearchResponse = {
|
||||
navigation: [],
|
||||
commands: [],
|
||||
entities: [],
|
||||
errors: [],
|
||||
correlationId: "initial-search",
|
||||
};
|
||||
|
||||
export function isCommandDeckShortcut(input: {
|
||||
readonly key: string;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly metaKey: boolean;
|
||||
readonly altKey?: boolean;
|
||||
readonly shiftKey?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.key.toLocaleLowerCase() === "k" &&
|
||||
(input.ctrlKey || input.metaKey) &&
|
||||
input.altKey !== true &&
|
||||
input.shiftKey !== true
|
||||
);
|
||||
}
|
||||
|
||||
export function commandDeckKeyAction(
|
||||
key: string,
|
||||
open: boolean,
|
||||
): CommandDeckKeyboardAction {
|
||||
if (!open) return null;
|
||||
if (key === "Escape") return "close";
|
||||
if (key === "ArrowDown") return "next";
|
||||
if (key === "ArrowUp") return "previous";
|
||||
if (key === "Enter") return "activate";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function nextCommandDeckIndex(
|
||||
current: number,
|
||||
count: number,
|
||||
direction: "next" | "previous",
|
||||
): number {
|
||||
if (count <= 0) return -1;
|
||||
if (direction === "next") return (current + 1 + count) % count;
|
||||
return (current - 1 + count) % count;
|
||||
}
|
||||
|
||||
export function restoreCommandDeckFocus(
|
||||
target: {
|
||||
focus(): void;
|
||||
} | null,
|
||||
): void {
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
export function projectHousekeepingSearchHref(
|
||||
href: HousekeepingNavigationHit["href"],
|
||||
) {
|
||||
return toHousekeepingHref(href);
|
||||
}
|
||||
|
||||
export function flattenHousekeepingSearchResults(
|
||||
response: HousekeepingSearchResponse,
|
||||
): readonly CommandDeckEntry[] {
|
||||
const group = (
|
||||
name: CommandDeckGroup,
|
||||
hits: readonly CommandDeckHit[],
|
||||
): readonly CommandDeckEntry[] =>
|
||||
hits.map((hit) => ({
|
||||
group: name,
|
||||
value: `${name}:${hit.domain}:${hit.id}`,
|
||||
hit,
|
||||
}));
|
||||
return [
|
||||
...group("navigation", response.navigation),
|
||||
...group("commands", response.commands),
|
||||
...group("entities", response.entities),
|
||||
];
|
||||
}
|
||||
|
||||
export function CommandDeck({
|
||||
labels,
|
||||
search,
|
||||
}: {
|
||||
readonly labels: CommandDeckLabels;
|
||||
readonly search: HousekeepingSearchExecutor;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
const [response, setResponse] =
|
||||
useState<HousekeepingSearchResponse>(EMPTY_RESPONSE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const wasOpen = useRef(false);
|
||||
const entries = useMemo(
|
||||
() => flattenHousekeepingSearchResults(response),
|
||||
[response],
|
||||
);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onShortcut = (event: globalThis.KeyboardEvent) => {
|
||||
if (!isCommandDeckShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
setOpen((current) => !current);
|
||||
};
|
||||
document.addEventListener("keydown", onShortcut);
|
||||
return () => document.removeEventListener("keydown", onShortcut);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (wasOpen.current) {
|
||||
wasOpen.current = false;
|
||||
restoreCommandDeckFocus(triggerRef.current);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const sequence = ++requestSequence.current;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => {
|
||||
search(term)
|
||||
.then((nextResponse) => {
|
||||
if (active && sequence === requestSequence.current) {
|
||||
setResponse(nextResponse);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active && sequence === requestSequence.current) {
|
||||
setResponse({
|
||||
...EMPTY_RESPONSE,
|
||||
errors: [{ providerId: "search", code: "INTERNAL" }],
|
||||
correlationId: "client-search",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active && sequence === requestSequence.current) setLoading(false);
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, search, term]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(entries.length === 0 ? -1 : 0);
|
||||
}, [entries.length]);
|
||||
|
||||
const activate = useCallback(
|
||||
(entry: CommandDeckEntry) => {
|
||||
const href = projectHousekeepingSearchHref(entry.hit.href);
|
||||
close();
|
||||
window.location.assign(href);
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const action = commandDeckKeyAction(event.key, open);
|
||||
if (action === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action === "close") {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (action === "activate") {
|
||||
const selected = entries[selectedIndex];
|
||||
if (selected) activate(selected);
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((current) =>
|
||||
nextCommandDeckIndex(current, entries.length, action),
|
||||
);
|
||||
};
|
||||
|
||||
const selectedValue = entries[selectedIndex]?.value ?? "";
|
||||
const renderGroup = (group: CommandDeckGroup, heading: string) => {
|
||||
const groupedEntries = entries.filter((entry) => entry.group === group);
|
||||
if (groupedEntries.length === 0) return null;
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{groupedEntries.map((entry) => (
|
||||
<CommandItem
|
||||
key={entry.value}
|
||||
value={entry.value}
|
||||
onSelect={() => activate(entry)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.hit.title}</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{entry.hit.domain}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<span>{labels.trigger}</span>
|
||||
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.trigger}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-2 sm:p-4 pt-4 sm:pt-[10vh]"
|
||||
>
|
||||
<div className="max-h-[calc(100dvh-2rem)] min-w-0 w-full max-w-2xl overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
|
||||
<div className="flex justify-end border-b border-[var(--admin-border)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.close}
|
||||
</button>
|
||||
</div>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => {
|
||||
const index = entries.findIndex(
|
||||
(entry) => entry.value === value,
|
||||
);
|
||||
if (index >= 0) setSelectedIndex(index);
|
||||
}}
|
||||
onKeyDownCapture={onKeyDown}
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={term}
|
||||
onValueChange={setTerm}
|
||||
placeholder={labels.placeholder}
|
||||
aria-label={labels.placeholder}
|
||||
/>
|
||||
<CommandList
|
||||
aria-busy={loading}
|
||||
className="min-w-0 overflow-y-auto"
|
||||
>
|
||||
{loading ? <p role="status">{labels.loading}</p> : null}
|
||||
{response.errors.length > 0 ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-3 py-2 text-sm"
|
||||
>
|
||||
{labels.partial}
|
||||
</p>
|
||||
) : null}
|
||||
{!loading ? <CommandEmpty>{labels.empty}</CommandEmpty> : null}
|
||||
{renderGroup("navigation", labels.navigation)}
|
||||
{renderGroup("commands", labels.commands)}
|
||||
{renderGroup("entities", labels.entities)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
CommandDeck,
|
||||
type CommandDeckLabels,
|
||||
type HousekeepingSearchExecutor,
|
||||
} from "./command-deck";
|
||||
|
||||
interface CommandTriggerProps {
|
||||
label: string;
|
||||
labels: Omit<CommandDeckLabels, "trigger">;
|
||||
search: HousekeepingSearchExecutor;
|
||||
}
|
||||
|
||||
export function CommandTrigger({ label, labels, search }: CommandTriggerProps) {
|
||||
return <CommandDeck labels={{ trigger: label, ...labels }} search={search} />;
|
||||
export function CommandTrigger({ label }: CommandTriggerProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,13 @@ interface ContextNavProps {
|
||||
|
||||
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
data-housekeeping-landmark="context-navigation"
|
||||
className="min-w-0"
|
||||
>
|
||||
<nav aria-label={ariaLabel} className="min-w-0">
|
||||
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="min-w-max">
|
||||
<Link
|
||||
href={item.href}
|
||||
className="block min-h-11 rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
|
||||
@@ -27,18 +27,14 @@ export function DomainRail({
|
||||
ariaLabel,
|
||||
}: DomainRailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
data-housekeeping-landmark="primary-navigation"
|
||||
className="min-w-0 w-full lg:w-20"
|
||||
>
|
||||
<nav aria-label={ariaLabel} className="w-full lg:w-20">
|
||||
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||
{domains.map((domain) => (
|
||||
<li key={domain.id} className="min-w-max lg:min-w-0">
|
||||
<Link
|
||||
href={domain.href}
|
||||
aria-current={domain.id === activeDomainId ? "page" : undefined}
|
||||
className="flex min-h-11 items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)] lg:justify-center"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
|
||||
>
|
||||
<span data-domain-icon={domain.iconId}>
|
||||
<DomainIcon iconId={domain.iconId} />
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import type { HousekeepingRouteDefinition } from "../contracts";
|
||||
import type { HousekeepingPreferences } from "../preferences/schema";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
import {
|
||||
type HousekeepingPreferenceSaver,
|
||||
preferencesAfterSave,
|
||||
} from "./widget-settings";
|
||||
|
||||
export function movePreferenceId(
|
||||
ids: readonly string[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): string[] {
|
||||
const from = ids.indexOf(activeId);
|
||||
const to = ids.indexOf(overId);
|
||||
if (from < 0 || to < 0 || from === to) return [...ids];
|
||||
return arrayMove([...ids], from, to);
|
||||
}
|
||||
|
||||
export function togglePinnedRoute(
|
||||
preferences: HousekeepingPreferences,
|
||||
routeId: string,
|
||||
): HousekeepingPreferences {
|
||||
const pinned = preferences.pinnedRouteIds.includes(routeId);
|
||||
if (pinned) {
|
||||
return {
|
||||
...preferences,
|
||||
pinnedRouteIds: preferences.pinnedRouteIds.filter((id) => id !== routeId),
|
||||
shortcutOrder: preferences.shortcutOrder.filter((id) => id !== routeId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...preferences,
|
||||
pinnedRouteIds: [...preferences.pinnedRouteIds, routeId],
|
||||
shortcutOrder: preferences.shortcutOrder.includes(routeId)
|
||||
? [...preferences.shortcutOrder]
|
||||
: [...preferences.shortcutOrder, routeId],
|
||||
};
|
||||
}
|
||||
|
||||
function orderedPinnedRouteIds(preferences: HousekeepingPreferences): string[] {
|
||||
const pinned = new Set(preferences.pinnedRouteIds);
|
||||
const ordered = preferences.shortcutOrder.filter((id) => pinned.has(id));
|
||||
for (const id of preferences.pinnedRouteIds) {
|
||||
if (!ordered.includes(id)) ordered.push(id);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function SortableFavorite({
|
||||
route,
|
||||
title,
|
||||
unpin,
|
||||
unpinLabel,
|
||||
reorderInstructionsId,
|
||||
}: {
|
||||
readonly route: HousekeepingRouteDefinition;
|
||||
readonly title: string;
|
||||
readonly unpin: () => void;
|
||||
readonly unpinLabel: string;
|
||||
readonly reorderInstructionsId: string;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
id: route.id,
|
||||
});
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] p-3 motion-reduce:transition-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={title}
|
||||
aria-describedby={reorderInstructionsId}
|
||||
className="cursor-grab rounded text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
<Link
|
||||
className="min-w-0 flex-1 break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
href={toHousekeepingHref(route.href)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={unpin}
|
||||
className="rounded-md px-2 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{unpinLabel}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FavoritesLabels {
|
||||
readonly title: string;
|
||||
readonly pin: string;
|
||||
readonly unpin: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
}
|
||||
|
||||
export function Favorites({
|
||||
preferences: initialPreferences,
|
||||
routes,
|
||||
savePreferences,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly savePreferences: HousekeepingPreferenceSaver;
|
||||
readonly labels: FavoritesLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const [preferences, setPreferences] = useState(initialPreferences);
|
||||
const [error, setError] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const routeById = new Map(routes.map((route) => [route.id, route]));
|
||||
const pinnedIds = orderedPinnedRouteIds(preferences).filter((id) =>
|
||||
routeById.has(id),
|
||||
);
|
||||
const unpinned = routes.filter(
|
||||
(route) => !preferences.pinnedRouteIds.includes(route.id),
|
||||
);
|
||||
|
||||
const commit = async (proposed: HousekeepingPreferences) => {
|
||||
setSaving(true);
|
||||
setError(false);
|
||||
try {
|
||||
const outcome = preferencesAfterSave(
|
||||
preferences,
|
||||
proposed,
|
||||
await savePreferences(proposed),
|
||||
);
|
||||
setPreferences(outcome.preferences);
|
||||
setError(outcome.error !== undefined);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
if (!event.over) return;
|
||||
const shortcutOrder = movePreferenceId(
|
||||
pinnedIds,
|
||||
String(event.active.id),
|
||||
String(event.over.id),
|
||||
);
|
||||
void commit({ ...preferences, shortcutOrder });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="housekeeping-favorites-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 id="housekeeping-favorites-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
<p id="housekeeping-favorites-reorder-instructions" className="sr-only">
|
||||
{labels.reorderInstructions}
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="text-[var(--admin-danger)]">
|
||||
{labels.error}
|
||||
</p>
|
||||
) : null}
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={pinnedIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{pinnedIds.map((id) => {
|
||||
const route = routeById.get(id);
|
||||
return route ? (
|
||||
<SortableFavorite
|
||||
key={id}
|
||||
route={route}
|
||||
title={titles[route.labelKey] ?? route.id}
|
||||
unpinLabel={labels.unpin}
|
||||
reorderInstructionsId="housekeeping-favorites-reorder-instructions"
|
||||
unpin={() => void commit(togglePinnedRoute(preferences, id))}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<ul className="space-y-2">
|
||||
{unpinned.map((route) => (
|
||||
<li
|
||||
key={route.id}
|
||||
className="flex min-w-0 items-center justify-between gap-3 p-2"
|
||||
>
|
||||
<span className="min-w-0 break-words">
|
||||
{titles[route.labelKey] ?? route.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void commit(togglePinnedRoute(preferences, route.id))
|
||||
}
|
||||
className="rounded-md px-2 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.pin}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import { HousekeepingShell } from "./housekeeping-shell";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
skipToContent: "Skip to housekeeping content",
|
||||
primaryNavigation: "Primary housekeeping navigation",
|
||||
contextualNavigation: "Operations navigation",
|
||||
command: "Search housekeeping",
|
||||
command: "Commands are unavailable in preview",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search navigation, commands, and entities",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Searching…",
|
||||
empty: "No results",
|
||||
partial: "Some providers are unavailable",
|
||||
close: "Close",
|
||||
},
|
||||
};
|
||||
|
||||
const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase/operations",
|
||||
href: "/admin-next/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Manage operations",
|
||||
items: [{ id: "queue", href: "/ase/operations/queue", label: "Queue" }],
|
||||
items: [
|
||||
{ id: "queue", href: "/admin-next/operations/queue", label: "Queue" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
href: "/ase/people",
|
||||
href: "/admin-next/people",
|
||||
iconId: "users",
|
||||
label: "People",
|
||||
description: "Manage people",
|
||||
@@ -47,7 +35,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
href: "/ase/content",
|
||||
href: "/admin-next/content",
|
||||
iconId: "file-text",
|
||||
label: "Content",
|
||||
description: "Manage content",
|
||||
@@ -55,7 +43,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
href: "/ase/economy",
|
||||
href: "/admin-next/economy",
|
||||
iconId: "gem",
|
||||
label: "Economy",
|
||||
description: "Manage economy",
|
||||
@@ -63,7 +51,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
href: "/ase/hotel",
|
||||
href: "/admin-next/hotel",
|
||||
iconId: "hotel",
|
||||
label: "Hotel",
|
||||
description: "Manage hotel",
|
||||
@@ -71,7 +59,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
href: "/ase/system",
|
||||
href: "/admin-next/system",
|
||||
iconId: "settings",
|
||||
label: "System",
|
||||
description: "Manage system",
|
||||
@@ -152,7 +140,6 @@ function renderShell(
|
||||
activeDomainId={activeDomainId}
|
||||
domains={availableDomains}
|
||||
labels={labels}
|
||||
search={vi.fn()}
|
||||
>
|
||||
<p>Deck body</p>
|
||||
</HousekeepingShell>,
|
||||
@@ -191,16 +178,16 @@ describe("HousekeepingShell", () => {
|
||||
expect(html).toContain('<nav aria-label="Operations navigation"');
|
||||
expect(html).toContain('<main id="housekeeping-content"');
|
||||
expect(html).toContain(">Deck body</p>");
|
||||
expect(html).toContain('href="/ase/operations/queue"');
|
||||
expect(html).toContain('href="/admin-next/operations/queue"');
|
||||
expect(html).toContain(">Queue<");
|
||||
expect(html).toContain(">Nora<");
|
||||
expect(html).toContain(">6<");
|
||||
expect(html).not.toContain(">Preview<");
|
||||
expect(html).toContain(">Preview<");
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain(">Back to site<");
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
expect(html).toContain(">Search housekeeping<");
|
||||
expect(html).not.toContain('disabled=""');
|
||||
expect(html).toMatch(
|
||||
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps all six authorized icons and exactly one active domain", () => {
|
||||
@@ -209,7 +196,7 @@ describe("HousekeepingShell", () => {
|
||||
const activeAnchors = allAnchors.filter((anchor) =>
|
||||
anchor.includes('aria-current="page"'),
|
||||
);
|
||||
const peopleAnchor = anchorForHref(html, "/ase/people");
|
||||
const peopleAnchor = anchorForHref(html, "/admin-next/people");
|
||||
for (const iconClass of [
|
||||
"lucide-inbox",
|
||||
"lucide-users",
|
||||
@@ -229,17 +216,13 @@ describe("HousekeepingShell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render a preview-only badge on the canonical surface", () => {
|
||||
expect(renderShell()).not.toContain(">Preview<");
|
||||
});
|
||||
|
||||
it("does not throw or render contextual items when a valid active domain is absent", () => {
|
||||
const domainsWithoutSystem = domains.filter(
|
||||
(domain) => domain.id !== "system",
|
||||
);
|
||||
expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow();
|
||||
const html = renderShell("system", domainsWithoutSystem);
|
||||
expect(html).not.toContain('href="/ase/operations/queue"');
|
||||
expect(html).not.toContain('href="/admin-next/operations/queue"');
|
||||
expect(html).not.toContain(">Queue<");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,20 +3,12 @@ import Link from "@/components/link";
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { HousekeepingActor } from "../contracts";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import type { HousekeepingSearchExecutor } from "./command-deck";
|
||||
import { CommandTrigger } from "./command-trigger";
|
||||
import { ContextNav } from "./context-nav";
|
||||
import { DomainRail } from "./domain-rail";
|
||||
import { OperatorSummary } from "./operator-summary";
|
||||
|
||||
export const HOUSEKEEPING_LANDMARKS = [
|
||||
"banner",
|
||||
"primary-navigation",
|
||||
"context-navigation",
|
||||
"main",
|
||||
] as const;
|
||||
|
||||
export interface HousekeepingShellProps {
|
||||
interface HousekeepingShellProps {
|
||||
actor: HousekeepingActor;
|
||||
activeDomainId: HousekeepingDomainId;
|
||||
domains: readonly HousekeepingNavigationDomain[];
|
||||
@@ -25,20 +17,9 @@ export interface HousekeepingShellProps {
|
||||
primaryNavigation: string;
|
||||
contextualNavigation: string;
|
||||
command: string;
|
||||
preview: string;
|
||||
backToSite: string;
|
||||
operatorRank: string;
|
||||
commandDeck: {
|
||||
placeholder: string;
|
||||
navigation: string;
|
||||
commands: string;
|
||||
entities: string;
|
||||
loading: string;
|
||||
empty: string;
|
||||
partial: string;
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
search: HousekeepingSearchExecutor;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -47,68 +28,50 @@ export function HousekeepingShell({
|
||||
activeDomainId,
|
||||
domains,
|
||||
labels,
|
||||
search,
|
||||
children,
|
||||
}: HousekeepingShellProps) {
|
||||
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-housekeeping-root="true"
|
||||
className="min-h-screen min-w-0 bg-[var(--admin-canvas)] text-[var(--admin-text)]"
|
||||
>
|
||||
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||
<a
|
||||
href="#housekeeping-content"
|
||||
className="sr-only rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)] focus-visible:not-sr-only focus-visible:absolute focus-visible:left-4 focus-visible:top-4 focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
|
||||
>
|
||||
{labels.skipToContent}
|
||||
</a>
|
||||
<header className="flex min-w-0 flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||
{labels.preview}
|
||||
</span>
|
||||
<OperatorSummary actor={actor} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandTrigger
|
||||
label={labels.command}
|
||||
labels={labels.commandDeck}
|
||||
search={search}
|
||||
/>
|
||||
<CommandTrigger label={labels.command} />
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{labels.backToSite}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
data-responsive-mode="stacked-below-lg"
|
||||
className="flex min-h-[calc(100vh-4rem)] min-w-0 flex-col lg:flex-row"
|
||||
>
|
||||
<aside
|
||||
data-navigation-collapse="domain"
|
||||
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r"
|
||||
>
|
||||
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
|
||||
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
|
||||
<DomainRail
|
||||
domains={domains}
|
||||
activeDomainId={activeDomainId}
|
||||
ariaLabel={labels.primaryNavigation}
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
data-navigation-collapse="context"
|
||||
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:shrink-0 lg:border-b-0 lg:border-r"
|
||||
>
|
||||
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
|
||||
<ContextNav
|
||||
items={activeDomain?.items ?? []}
|
||||
ariaLabel={labels.contextualNavigation}
|
||||
/>
|
||||
</div>
|
||||
<main
|
||||
id="housekeeping-content"
|
||||
tabIndex={-1}
|
||||
className="housekeeping-content min-w-0 max-w-full flex-1 p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--admin-accent)] lg:p-6"
|
||||
>
|
||||
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { anyCapability, type HousekeepingWorkItem } from "../contracts";
|
||||
import {
|
||||
filterOperationalInboxItems,
|
||||
OperationalInbox,
|
||||
projectOperationalInboxHref,
|
||||
} from "./operational-inbox";
|
||||
|
||||
const labels = {
|
||||
title: "Operational inbox",
|
||||
domainFilter: "Filter by domain",
|
||||
stateFilter: "Filter by state",
|
||||
priorityFilter: "Filter by priority",
|
||||
all: "All",
|
||||
loading: "Loading operational work",
|
||||
empty: "No operational work",
|
||||
partial: "Some sources are unavailable",
|
||||
open: "Open workflow",
|
||||
};
|
||||
|
||||
function item(
|
||||
itemId: string,
|
||||
options: Partial<HousekeepingWorkItem> = {},
|
||||
): HousekeepingWorkItem {
|
||||
return {
|
||||
sourceId: "people.queue",
|
||||
itemId,
|
||||
deduplicationKey: `people.queue:${itemId}`,
|
||||
domain: "people",
|
||||
capability: anyCapability("admin.users.view"),
|
||||
severity: "warning",
|
||||
priority: "high",
|
||||
ageMs: 1_000,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-30T10:00:00.000Z",
|
||||
titleKey: `Item ${itemId}`,
|
||||
href: "/ase/people/users",
|
||||
freshness: "fresh",
|
||||
actions: [],
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OperationalInbox", () => {
|
||||
it("filters deterministically by domain, state, and priority", () => {
|
||||
const items = [
|
||||
item("people-open"),
|
||||
item("content-draft", {
|
||||
domain: "content",
|
||||
state: "draft",
|
||||
priority: "normal",
|
||||
href: "/ase/content/editorial/articles",
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterOperationalInboxItems(items, {
|
||||
domain: "people",
|
||||
state: "open",
|
||||
priority: "high",
|
||||
}).map((entry) => entry.itemId),
|
||||
).toEqual(["people-open"]);
|
||||
expect(
|
||||
filterOperationalInboxItems(items, {
|
||||
domain: "all",
|
||||
state: "draft",
|
||||
priority: "all",
|
||||
}).map((entry) => entry.itemId),
|
||||
).toEqual(["content-draft"]);
|
||||
});
|
||||
|
||||
it("renders accessible filters, partial errors, and preview links", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OperationalInbox
|
||||
labels={labels}
|
||||
response={{
|
||||
items: [item("ticket-1")],
|
||||
errors: [{ sourceId: "content.publication", code: "TIMEOUT" }],
|
||||
correlationId: "inbox-ui",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="Filter by domain"');
|
||||
expect(html).toContain('aria-label="Filter by state"');
|
||||
expect(html).toContain('aria-label="Filter by priority"');
|
||||
expect(html).toContain("Some sources are unavailable");
|
||||
expect(html).not.toContain("content.publication");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/ase/people/users"');
|
||||
expect(html).not.toContain("Dismiss");
|
||||
expect(html).not.toContain("Assign");
|
||||
});
|
||||
|
||||
it("renders explicit loading and empty states", () => {
|
||||
const response = {
|
||||
items: [],
|
||||
errors: [],
|
||||
correlationId: "inbox-empty",
|
||||
} as const;
|
||||
const loading = renderToStaticMarkup(
|
||||
<OperationalInbox labels={labels} response={response} loading />,
|
||||
);
|
||||
const empty = renderToStaticMarkup(
|
||||
<OperationalInbox labels={labels} response={response} />,
|
||||
);
|
||||
expect(loading).toContain('role="status"');
|
||||
expect(loading).toContain("Loading operational work");
|
||||
expect(empty).toContain("No operational work");
|
||||
});
|
||||
|
||||
it("projects canonical hrefs without changing production destinations", () => {
|
||||
expect(projectOperationalInboxHref("/ase/system/operations/alerts")).toBe(
|
||||
"/ase/system/operations/alerts",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import type {
|
||||
CanonicalHousekeepingHref,
|
||||
HousekeepingWorkItem,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingInboxResponse } from "../inbox/inbox-service";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
|
||||
export interface OperationalInboxLabels {
|
||||
readonly title: string;
|
||||
readonly domainFilter: string;
|
||||
readonly stateFilter: string;
|
||||
readonly priorityFilter: string;
|
||||
readonly all: string;
|
||||
readonly loading: string;
|
||||
readonly empty: string;
|
||||
readonly partial: string;
|
||||
readonly open: string;
|
||||
}
|
||||
|
||||
export interface OperationalInboxFilters {
|
||||
readonly domain: string;
|
||||
readonly state: string;
|
||||
readonly priority: string;
|
||||
}
|
||||
|
||||
export function filterOperationalInboxItems(
|
||||
items: readonly HousekeepingWorkItem[],
|
||||
filters: OperationalInboxFilters,
|
||||
): readonly HousekeepingWorkItem[] {
|
||||
return items.filter(
|
||||
(item) =>
|
||||
(filters.domain === "all" || item.domain === filters.domain) &&
|
||||
(filters.state === "all" || item.state === filters.state) &&
|
||||
(filters.priority === "all" || item.priority === filters.priority),
|
||||
);
|
||||
}
|
||||
|
||||
export function projectOperationalInboxHref(href: CanonicalHousekeepingHref) {
|
||||
return toHousekeepingHref(href);
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): readonly string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function OperationalInbox({
|
||||
response,
|
||||
labels,
|
||||
loading = false,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly response: HousekeepingInboxResponse;
|
||||
readonly labels: OperationalInboxLabels;
|
||||
readonly loading?: boolean;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const [domain, setDomain] = useState("all");
|
||||
const [state, setState] = useState("all");
|
||||
const [priority, setPriority] = useState("all");
|
||||
const domains = useMemo(
|
||||
() => unique(response.items.map((item) => item.domain)),
|
||||
[response.items],
|
||||
);
|
||||
const states = useMemo(
|
||||
() => unique(response.items.map((item) => item.state)),
|
||||
[response.items],
|
||||
);
|
||||
const priorities = useMemo(
|
||||
() => unique(response.items.map((item) => item.priority)),
|
||||
[response.items],
|
||||
);
|
||||
const items = useMemo(
|
||||
() =>
|
||||
filterOperationalInboxItems(response.items, {
|
||||
domain,
|
||||
state,
|
||||
priority,
|
||||
}),
|
||||
[domain, priority, response.items, state],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="operational-inbox-title"
|
||||
className="min-w-0 space-y-4"
|
||||
>
|
||||
<h2 id="operational-inbox-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
{loading ? <p role="status">{labels.loading}</p> : null}
|
||||
{!loading ? (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<select
|
||||
aria-label={labels.domainFilter}
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{domains.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label={labels.stateFilter}
|
||||
value={state}
|
||||
onChange={(event) => setState(event.target.value)}
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{states.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label={labels.priorityFilter}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{priorities.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{response.errors.length > 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
|
||||
>
|
||||
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.length === 0 ? <p>{labels.empty}</p> : null}
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={`${item.sourceId}:${item.itemId}`}
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 break-words">
|
||||
<p className="font-medium">
|
||||
{titles[item.titleKey] ?? item.titleKey}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{item.domain} · {item.state} · {item.priority}
|
||||
</p>
|
||||
<time dateTime={item.occurredAt} className="text-xs">
|
||||
{item.occurredAt}
|
||||
</time>
|
||||
</div>
|
||||
<Link
|
||||
href={projectOperationalInboxHref(item.href)}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.open}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import type { HousekeepingActor } from "../contracts";
|
||||
|
||||
interface OperatorSummaryProps {
|
||||
actor: HousekeepingActor;
|
||||
rankLabel: string;
|
||||
}
|
||||
|
||||
export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
|
||||
export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||
return (
|
||||
<div className="min-w-0 text-sm text-[var(--admin-text)]">
|
||||
<div className="truncate font-medium">{actor.username}</div>
|
||||
@@ -13,8 +12,7 @@ export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
|
||||
data-operator-rank={actor.rank}
|
||||
className="text-[var(--admin-text-muted)]"
|
||||
>
|
||||
<span className="sr-only">{rankLabel}</span>
|
||||
<span aria-hidden="true">{actor.rank}</span>
|
||||
{actor.rank}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { defaultHousekeepingPreferences } from "../preferences/schema";
|
||||
import {
|
||||
createHousekeepingRegistry,
|
||||
loadHousekeepingWidgets,
|
||||
} from "../registry";
|
||||
import { Favorites, movePreferenceId, togglePinnedRoute } from "./favorites";
|
||||
import { RecentWork } from "./recent-work";
|
||||
import { WidgetGrid } from "./widget-grid";
|
||||
import {
|
||||
preferencesAfterSave,
|
||||
toggleOptionalWidget,
|
||||
WidgetSettings,
|
||||
} from "./widget-settings";
|
||||
|
||||
const usersView = anyCapability(PERMS.USERS_VIEW);
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_VIEW,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
const registry = createHousekeepingRegistry([
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "people",
|
||||
descriptionKey: "people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: usersView,
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
labelKey: "Users",
|
||||
href: "/ase/people/users",
|
||||
capability: usersView,
|
||||
},
|
||||
{
|
||||
id: "people.tickets",
|
||||
labelKey: "Tickets",
|
||||
href: "/ase/people/tickets",
|
||||
capability: usersView,
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [
|
||||
{
|
||||
id: "people.mandatory",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "mandatory",
|
||||
load: async () => ok({ count: 1 }, "mandatory"),
|
||||
},
|
||||
{
|
||||
id: "people.optional",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "optional",
|
||||
load: async () => ok({ count: 2 }, "optional"),
|
||||
},
|
||||
{
|
||||
id: "people.failing",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "optional",
|
||||
load: async () => fail("TIMEOUT", "timeout", "failing"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const initialPreferences = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.users"],
|
||||
shortcutOrder: ["people.users"],
|
||||
widgetOrder: ["stale.widget", "people.optional", "people.mandatory"],
|
||||
enabledOptionalWidgetIds: ["people.optional", "people.failing"],
|
||||
};
|
||||
|
||||
const save = vi.fn(async (value) => ok(value, "saved"));
|
||||
|
||||
describe("housekeeping personalization", () => {
|
||||
it("loads enabled widgets independently in reconciled order and reports partial failures", async () => {
|
||||
const result = await loadHousekeepingWidgets(
|
||||
registry,
|
||||
initialPreferences,
|
||||
context,
|
||||
);
|
||||
expect(result.widgets).toEqual([
|
||||
{ id: "people.optional", data: { count: 2 } },
|
||||
{ id: "people.mandatory", data: { count: 1 } },
|
||||
]);
|
||||
expect(result.errors).toEqual([
|
||||
{ widgetId: "people.failing", code: "TIMEOUT" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pins, unpins, and reorders only stable preference identifiers", () => {
|
||||
const pinned = togglePinnedRoute(initialPreferences, "people.tickets");
|
||||
expect(pinned.pinnedRouteIds).toEqual(["people.users", "people.tickets"]);
|
||||
expect(pinned.shortcutOrder).toEqual(["people.users", "people.tickets"]);
|
||||
expect(
|
||||
movePreferenceId(pinned.shortcutOrder, "people.tickets", "people.users"),
|
||||
).toEqual(["people.tickets", "people.users"]);
|
||||
expect(togglePinnedRoute(pinned, "people.users")).toMatchObject({
|
||||
pinnedRouteIds: ["people.tickets"],
|
||||
shortcutOrder: ["people.tickets"],
|
||||
});
|
||||
});
|
||||
|
||||
it("locks mandatory widgets, toggles optional widgets, and retains UI state after a failed save", () => {
|
||||
const enabled = toggleOptionalWidget(initialPreferences, "people.optional");
|
||||
expect(enabled.enabledOptionalWidgetIds).toEqual(["people.failing"]);
|
||||
const failure = fail(
|
||||
"INTERNAL",
|
||||
"errors.housekeeping.preferences.save",
|
||||
"save-failed",
|
||||
);
|
||||
expect(preferencesAfterSave(initialPreferences, enabled, failure)).toEqual({
|
||||
preferences: initialPreferences,
|
||||
error: "errors.housekeeping.preferences.save",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders favorites, recent links, locked settings, keyboard ordering, and widget errors accessibly", () => {
|
||||
const widgets = {
|
||||
widgets: [
|
||||
{ id: "people.optional", data: { count: 2 } },
|
||||
{ id: "people.mandatory", data: { count: 1 } },
|
||||
],
|
||||
errors: [{ widgetId: "people.failing", code: "TIMEOUT" as const }],
|
||||
};
|
||||
const routes = registry.domains[0]?.routes ?? [];
|
||||
const definitions = registry.domains[0]?.widgets ?? [];
|
||||
const html = renderToStaticMarkup(
|
||||
<>
|
||||
<Favorites
|
||||
preferences={initialPreferences}
|
||||
routes={routes}
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Save failed",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
}}
|
||||
/>
|
||||
<RecentWork
|
||||
items={[
|
||||
{
|
||||
routeId: "people.users",
|
||||
canonicalHref: "/ase/people/users",
|
||||
labelKey: "Users",
|
||||
occurredAt: "2026-08-30T10:00:00.000Z",
|
||||
source: "route-visit",
|
||||
},
|
||||
]}
|
||||
labels={{ title: "Recent work", empty: "Nothing recent" }}
|
||||
/>
|
||||
<WidgetSettings
|
||||
preferences={initialPreferences}
|
||||
widgets={definitions}
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Widget settings",
|
||||
mandatory: "Required",
|
||||
error: "Save failed",
|
||||
}}
|
||||
/>
|
||||
<WidgetGrid
|
||||
preferences={initialPreferences}
|
||||
result={widgets}
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Some widgets failed",
|
||||
error: "Save failed",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(html).toContain('href="/ase/people/users"');
|
||||
expect(html).toContain("Required");
|
||||
expect(html).toContain('disabled=""');
|
||||
expect(html).toContain("Some widgets failed");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
const gridSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/features/housekeeping/foundation/shell/widget-grid.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(gridSource).toContain("KeyboardSensor");
|
||||
expect(gridSource).toContain("sortableKeyboardCoordinates");
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import Link from "@/components/link";
|
||||
import type { HousekeepingRecentItem } from "../recent/recent-work";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
|
||||
export interface RecentWorkLabels {
|
||||
readonly title: string;
|
||||
readonly empty: string;
|
||||
}
|
||||
|
||||
export function RecentWork({
|
||||
items,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly items: readonly HousekeepingRecentItem[];
|
||||
readonly labels: RecentWorkLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="housekeeping-recent-title"
|
||||
className="min-w-0 space-y-3"
|
||||
>
|
||||
<h2 id="housekeeping-recent-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
{items.length === 0 ? <p>{labels.empty}</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.routeId}
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||
>
|
||||
<Link
|
||||
href={toHousekeepingHref(item.canonicalHref)}
|
||||
className="break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{titles[item.labelKey] ?? item.routeId}
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">
|
||||
<span>{item.source}</span>
|
||||
{" · "}
|
||||
<time dateTime={item.occurredAt}>{item.occurredAt}</time>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useState } from "react";
|
||||
import type { HousekeepingPreferences } from "../preferences/schema";
|
||||
import type { HousekeepingWidgetLoadResult } from "../registry";
|
||||
import { movePreferenceId } from "./favorites";
|
||||
import {
|
||||
type HousekeepingPreferenceSaver,
|
||||
preferencesAfterSave,
|
||||
} from "./widget-settings";
|
||||
|
||||
function SortableWidget({
|
||||
id,
|
||||
title,
|
||||
data,
|
||||
reorderInstructionsId,
|
||||
}: {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly data: unknown;
|
||||
readonly reorderInstructionsId: string;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4 motion-reduce:transition-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={title}
|
||||
aria-describedby={reorderInstructionsId}
|
||||
className="cursor-grab rounded text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
<h3 className="min-w-0 break-words font-medium">{title}</h3>
|
||||
</div>
|
||||
<pre className="mt-2 max-w-full overflow-auto text-xs">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WidgetGridLabels {
|
||||
readonly title: string;
|
||||
readonly partial: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
}
|
||||
|
||||
export function WidgetGrid({
|
||||
preferences: initialPreferences,
|
||||
result,
|
||||
savePreferences,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly result: HousekeepingWidgetLoadResult;
|
||||
readonly savePreferences: HousekeepingPreferenceSaver;
|
||||
readonly labels: WidgetGridLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const [preferences, setPreferences] = useState(initialPreferences);
|
||||
const [error, setError] = useState(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const dataById = new Map(
|
||||
result.widgets.map((widget) => [widget.id, widget.data]),
|
||||
);
|
||||
const ids = preferences.widgetOrder.filter((id) => dataById.has(id));
|
||||
for (const widget of result.widgets) {
|
||||
if (!ids.includes(widget.id)) ids.push(widget.id);
|
||||
}
|
||||
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
if (!event.over) return;
|
||||
const moved = movePreferenceId(
|
||||
ids,
|
||||
String(event.active.id),
|
||||
String(event.over.id),
|
||||
);
|
||||
const retained = preferences.widgetOrder.filter((id) => !ids.includes(id));
|
||||
const proposed = { ...preferences, widgetOrder: [...moved, ...retained] };
|
||||
setError(false);
|
||||
try {
|
||||
const outcome = preferencesAfterSave(
|
||||
preferences,
|
||||
proposed,
|
||||
await savePreferences(proposed),
|
||||
);
|
||||
setPreferences(outcome.preferences);
|
||||
setError(outcome.error !== undefined);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="housekeeping-widget-grid-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 id="housekeeping-widget-grid-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
<p id="housekeeping-widgets-reorder-instructions" className="sr-only">
|
||||
{labels.reorderInstructions}
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="text-[var(--admin-danger)]">
|
||||
{labels.error}
|
||||
</p>
|
||||
) : null}
|
||||
{result.errors.length > 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
|
||||
>
|
||||
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={(event) => void onDragEnd(event)}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{ids.map((id) => (
|
||||
<SortableWidget
|
||||
key={id}
|
||||
id={id}
|
||||
title={titles[id] ?? id}
|
||||
data={dataById.get(id)}
|
||||
reorderInstructionsId="housekeeping-widgets-reorder-instructions"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { HousekeepingResult } from "../contracts";
|
||||
import type { HousekeepingPreferences } from "../preferences/schema";
|
||||
import type { HousekeepingWidgetOption } from "../preferences/widget-options";
|
||||
|
||||
export type HousekeepingPreferenceSaver = (
|
||||
preferences: HousekeepingPreferences,
|
||||
) => Promise<HousekeepingResult<HousekeepingPreferences>>;
|
||||
|
||||
export function toggleOptionalWidget(
|
||||
preferences: HousekeepingPreferences,
|
||||
widgetId: string,
|
||||
): HousekeepingPreferences {
|
||||
const enabled = preferences.enabledOptionalWidgetIds.includes(widgetId);
|
||||
return {
|
||||
...preferences,
|
||||
widgetOrder:
|
||||
!enabled && !preferences.widgetOrder.includes(widgetId)
|
||||
? [...preferences.widgetOrder, widgetId]
|
||||
: [...preferences.widgetOrder],
|
||||
enabledOptionalWidgetIds: enabled
|
||||
? preferences.enabledOptionalWidgetIds.filter((id) => id !== widgetId)
|
||||
: [...preferences.enabledOptionalWidgetIds, widgetId],
|
||||
};
|
||||
}
|
||||
|
||||
export function preferencesAfterSave(
|
||||
current: HousekeepingPreferences,
|
||||
_proposed: HousekeepingPreferences,
|
||||
result: HousekeepingResult<HousekeepingPreferences>,
|
||||
): { preferences: HousekeepingPreferences; error?: string } {
|
||||
return result.ok
|
||||
? { preferences: result.data }
|
||||
: { preferences: current, error: result.error.messageKey };
|
||||
}
|
||||
|
||||
export interface WidgetSettingsLabels {
|
||||
readonly title: string;
|
||||
readonly mandatory: string;
|
||||
readonly error: string;
|
||||
}
|
||||
|
||||
export function WidgetSettings({
|
||||
preferences: initialPreferences,
|
||||
widgets,
|
||||
savePreferences,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly widgets: readonly HousekeepingWidgetOption[];
|
||||
readonly savePreferences: HousekeepingPreferenceSaver;
|
||||
readonly labels: WidgetSettingsLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const [preferences, setPreferences] = useState(initialPreferences);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const toggle = async (widgetId: string) => {
|
||||
const proposed = toggleOptionalWidget(preferences, widgetId);
|
||||
setSavingId(widgetId);
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = preferencesAfterSave(
|
||||
preferences,
|
||||
proposed,
|
||||
await savePreferences(proposed),
|
||||
);
|
||||
setPreferences(outcome.preferences);
|
||||
setError(outcome.error ?? null);
|
||||
} catch {
|
||||
setError(labels.error);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="housekeeping-widget-settings-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2
|
||||
id="housekeeping-widget-settings-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{labels.title}
|
||||
</h2>
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="text-[var(--admin-danger)]"
|
||||
>
|
||||
{error === labels.error ? error : labels.error}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="space-y-2">
|
||||
{widgets.map((widget) => {
|
||||
const mandatory = widget.kind === "mandatory";
|
||||
const checked =
|
||||
mandatory ||
|
||||
preferences.enabledOptionalWidgetIds.includes(widget.id);
|
||||
return (
|
||||
<li
|
||||
key={widget.id}
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
<label className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={mandatory || savingId !== null}
|
||||
onChange={() => void toggle(widget.id)}
|
||||
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
/>
|
||||
<span className="min-w-0 break-words">
|
||||
{titles[widget.id] ?? widget.id}
|
||||
</span>
|
||||
</label>
|
||||
{mandatory ? (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{labels.mandatory}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user