fix(housekeeping): require reasons for destructive changes

This commit is contained in:
Simo committed 2026-09-02 22:12:58 +02:00
1 parent 50598472ad
commit 93367a239f
16 files changed
+323 -15

No files matched your search

@@ -7,13 +7,13 @@ import type { HousekeepingCommand } from "./registry";
function command(
requiresReason: boolean,
risk: "safe" | "sensitive",
): HousekeepingCommand<Record<string, never>, null> {
): HousekeepingCommand<Record<string, unknown>, null> {
return {
id: "system.confirmation.test",
owner: "system",
risk,
capability: anyCapability("admin.settings.edit"),
input: z.object({}),
input: z.object({}).catchall(z.unknown()),
requiresReason,
rateLimit: { attempts: 2, windowMs: 60_000 },
execute: async (context) => ok(null, context.correlationId),
@@ -75,4 +75,23 @@ describe("housekeeping command confirmation", () => {
correlationId: "corr-safe",
});
});
it("requires a reason dynamically when parsed input binds a delete action", () => {
expect(
confirmHousekeepingCommand(
command(false, "sensitive"),
" \t ",
"corr-delete-reason",
{ action: "delete", id: "7" },
),
).toEqual({
ok: false,
error: {
code: "VALIDATION",
messageKey: "errors.housekeeping.validation",
fieldErrors: { reason: ["errors.validation.required"] },
},
correlationId: "corr-delete-reason",
});
});
});
@@ -1,4 +1,8 @@
import { fail, type HousekeepingResult, ok } from "../contracts";
import {
normalizeHousekeepingReason,
requiresHousekeepingReason,
} from "./reason-policy";
import type { HousekeepingCommand } from "./registry";
export interface HousekeepingCommandConfirmation {
@@ -12,9 +16,14 @@ export function confirmHousekeepingCommand<I, O>(
command: HousekeepingCommand<I, O>,
reason: string | undefined,
correlationId: string,
input?: I,
): HousekeepingResult<HousekeepingCommandConfirmation> {
const normalizedReason = reason?.normalize("NFC").trim() || undefined;
if (command.requiresReason && !normalizedReason) {
const normalizedReason = normalizeHousekeepingReason(reason);
const reasonRequired = requiresHousekeepingReason(
command.requiresReason,
input,
);
if (reasonRequired && !normalizedReason) {
return fail("VALIDATION", "errors.housekeeping.validation", correlationId, {
reason: ["errors.validation.required"],
});
@@ -24,7 +33,7 @@ export function confirmHousekeepingCommand<I, O>(
{
commandId: command.id,
risk: command.risk,
requiresReason: command.requiresReason,
requiresReason: reasonRequired,
...(normalizedReason === undefined ? {} : { reason: normalizedReason }),
},
correlationId,
@@ -374,6 +374,53 @@ describe("dispatchHousekeepingCommand", () => {
expect(audit.entries.map((entry) => entry.outcome)).toEqual(["denied"]);
});
it("requires and forwards a normalized reason for a dynamically destructive input", async () => {
let executionReason: string | undefined;
let executions = 0;
register(
baseCommand("system.dispatch.dynamic-delete-reason", {
risk: "sensitive",
input: z.object({
action: z.enum(["update", "delete"]),
id: z.string(),
}),
execute: async (context) => {
executions += 1;
executionReason = context.reason;
return ok(null, context.correlationId);
},
}),
);
const denied = await dispatchHousekeepingCommand(
{
commandId: "system.dispatch.dynamic-delete-reason",
input: { action: "delete", id: "7" },
},
dependencies(),
);
expect(denied).toMatchObject({
ok: false,
error: {
code: "VALIDATION",
fieldErrors: { reason: ["errors.validation.required"] },
},
});
expect(executions).toBe(0);
const allowed = await dispatchHousekeepingCommand(
{
commandId: "system.dispatch.dynamic-delete-reason",
input: { action: "delete", id: "7" },
reason: " Remove duplicate ",
},
dependencies(),
);
expect(allowed).toMatchObject({ ok: true });
expect(executions).toBe(1);
expect(executionReason).toBe("Remove duplicate");
});
it("uses actor, IP, command ID, and the approved command limit", async () => {
const calls: Array<[string, number, number]> = [];
let executed = false;
@@ -223,6 +223,7 @@ export async function dispatchHousekeepingCommand(
command,
commandRequest.reason || undefined,
correlationId,
parsedInput.data,
);
} catch (error) {
return persistReturnedOutcome(
@@ -277,6 +278,7 @@ export async function dispatchHousekeepingCommand(
capability: dependencies.context,
correlationId,
ipAddress,
...(commandRequest.reason ? { reason: commandRequest.reason } : {}),
};
let correlatedResult: HousekeepingResult<unknown>;
try {
@@ -0,0 +1,23 @@
export function normalizeHousekeepingReason(
reason: string | undefined,
): string | undefined {
return reason?.normalize("NFC").trim() || undefined;
}
export function isHousekeepingDeleteInput(input: unknown): boolean {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return false;
}
try {
return Reflect.get(input, "action") === "delete";
} catch {
return false;
}
}
export function requiresHousekeepingReason(
commandRequiresReason: boolean,
input: unknown,
): boolean {
return commandRequiresReason || isHousekeepingDeleteInput(input);
}
@@ -14,6 +14,7 @@ export interface HousekeepingCommandContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly ipAddress: string;
readonly reason?: string;
}
export interface HousekeepingCommand<I, O> {