feat(housekeeping): dispatch audited commands
This commit is contained in:
1 parent
ca666d9f90
commit
6aed71a8b2
8 files changed
+996
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
dispatchHousekeepingCommand,
|
||||||
|
type HousekeepingCommandRequest,
|
||||||
|
} from "@/features/housekeeping/foundation/commands/dispatcher";
|
||||||
|
import type { HousekeepingResult } from "@/features/housekeeping/foundation/contracts";
|
||||||
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { housekeepingAuditWriter } from "@/lib/services/audit";
|
||||||
|
|
||||||
|
export async function executeHousekeepingCommand(
|
||||||
|
request: HousekeepingCommandRequest,
|
||||||
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
|
const [context, ipAddress] = await Promise.all([
|
||||||
|
getHousekeepingCapabilityContext(),
|
||||||
|
clientIp(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return dispatchHousekeepingCommand(request, {
|
||||||
|
context,
|
||||||
|
ipAddress,
|
||||||
|
audit: housekeepingAuditWriter,
|
||||||
|
rateLimit: async (key, attempts, windowMs) =>
|
||||||
|
(await rateLimit(key, attempts, windowMs)).ok,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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(
|
||||||
|
command: HousekeepingCommand<unknown, unknown>,
|
||||||
|
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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anyCapability, ok } from "../contracts";
|
||||||
|
import {
|
||||||
|
getHousekeepingCommand,
|
||||||
|
type HousekeepingCommand,
|
||||||
|
registerHousekeepingCommand,
|
||||||
|
} 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("housekeeping command registry", () => {
|
||||||
|
it("returns the exact command registered under its global ID", () => {
|
||||||
|
const registered = command("people.registry.lookup");
|
||||||
|
|
||||||
|
registerHousekeepingCommand(registered);
|
||||||
|
|
||||||
|
expect(getHousekeepingCommand(registered.id)).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
import {
|
||||||
|
HOUSEKEEPING_DOMAIN_IDS,
|
||||||
|
type HousekeepingDomainId,
|
||||||
|
} from "../../migration/types";
|
||||||
|
import type {
|
||||||
|
CapabilityRequirement,
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
HousekeepingResult,
|
||||||
|
} from "../contracts";
|
||||||
|
|
||||||
|
export interface HousekeepingCommandContext {
|
||||||
|
capability: HousekeepingCapabilityContext;
|
||||||
|
correlationId: string;
|
||||||
|
ipAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingCommand<I, O> {
|
||||||
|
id: string;
|
||||||
|
owner: HousekeepingDomainId;
|
||||||
|
risk: "safe" | "sensitive";
|
||||||
|
capability: CapabilityRequirement;
|
||||||
|
input: z.ZodType<I>;
|
||||||
|
requiresReason: boolean;
|
||||||
|
rateLimit: { attempts: number; windowMs: number };
|
||||||
|
execute(
|
||||||
|
context: HousekeepingCommandContext,
|
||||||
|
input: I,
|
||||||
|
): Promise<HousekeepingResult<O>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisteredHousekeepingCommand = HousekeepingCommand<unknown, unknown>;
|
||||||
|
|
||||||
|
const approvedOwners = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||||
|
const commands = new Map<string, RegisteredHousekeepingCommand>();
|
||||||
|
|
||||||
|
export function registerHousekeepingCommand<I, O>(
|
||||||
|
command: HousekeepingCommand<I, O>,
|
||||||
|
): void {
|
||||||
|
validateCommand(command);
|
||||||
|
if (commands.has(command.id)) {
|
||||||
|
throw new Error(`duplicate command id: ${command.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.set(command.id, command as RegisteredHousekeepingCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHousekeepingCommand(
|
||||||
|
id: string,
|
||||||
|
): RegisteredHousekeepingCommand | undefined {
|
||||||
|
return commands.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||||
|
if (typeof command.id !== "string" || !command.id.trim()) {
|
||||||
|
throw new Error("empty command id");
|
||||||
|
}
|
||||||
|
if (!approvedOwners.has(command.owner)) {
|
||||||
|
throw new Error(`unknown command owner: ${command.owner}`);
|
||||||
|
}
|
||||||
|
if (!command.id.startsWith(`${command.owner}.`)) {
|
||||||
|
throw new Error(`command owner mismatch: ${command.id}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!command.input ||
|
||||||
|
typeof (command.input as { safeParse?: unknown }).safeParse !== "function"
|
||||||
|
) {
|
||||||
|
throw new Error(`command input schema missing: ${command.id}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user