From 6aed71a8b2bf0e016a1655a9eb637fd5c2180282 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 27 Aug 2026 18:30:44 +0200 Subject: [PATCH] feat(housekeeping): dispatch audited commands --- src/actions/housekeeping-command.test.ts | 112 +++++ src/actions/housekeeping-command.ts | 27 ++ .../foundation/commands/confirmation.test.ts | 78 ++++ .../foundation/commands/confirmation.ts | 32 ++ .../foundation/commands/dispatcher.test.ts | 395 ++++++++++++++++++ .../foundation/commands/dispatcher.ts | 224 ++++++++++ .../foundation/commands/registry.test.ts | 50 +++ .../foundation/commands/registry.ts | 78 ++++ 8 files changed, 996 insertions(+) create mode 100644 src/actions/housekeeping-command.test.ts create mode 100644 src/actions/housekeeping-command.ts create mode 100644 src/features/housekeeping/foundation/commands/confirmation.test.ts create mode 100644 src/features/housekeeping/foundation/commands/confirmation.ts create mode 100644 src/features/housekeeping/foundation/commands/dispatcher.test.ts create mode 100644 src/features/housekeeping/foundation/commands/dispatcher.ts create mode 100644 src/features/housekeeping/foundation/commands/registry.test.ts create mode 100644 src/features/housekeeping/foundation/commands/registry.ts diff --git a/src/actions/housekeeping-command.test.ts b/src/actions/housekeeping-command.test.ts new file mode 100644 index 00000000..f2adf81b --- /dev/null +++ b/src/actions/housekeeping-command.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry"; +import { + anyCapability, + ok, +} from "@/features/housekeeping/foundation/contracts"; +import type { AuditEntry } from "@/lib/services/audit"; + +const { auditEntries, context, rateLimitCalls } = vi.hoisted(() => ({ + auditEntries: [] as AuditEntry[], + context: { + actor: { id: 71, username: "server-operator", rank: 4 }, + isSuperAdmin: false, + has: (slug: string) => slug === "admin.settings.edit", + hasAny: (...slugs: string[]) => slugs.includes("admin.settings.edit"), + hasAll: (...slugs: string[]) => + slugs.every((slug) => slug === "admin.settings.edit"), + }, + rateLimitCalls: [] as Array<[string, number, number]>, +})); + +vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ + getHousekeepingCapabilityContext: async () => context, +})); + +vi.mock("@/lib/services/audit", () => ({ + housekeepingAuditWriter: { + write: async (entry: AuditEntry) => { + auditEntries.push({ ...entry }); + }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + clientIp: async () => "203.0.113.7", + rateLimit: async (key: string, attempts: number, windowMs: number) => { + rateLimitCalls.push([key, attempts, windowMs]); + return { ok: true, retryAfter: 0 }; + }, +})); + +import { executeHousekeepingCommand } from "./housekeeping-command"; + +registerHousekeepingCommand({ + id: "system.server-action.serializable", + owner: "system", + risk: "safe", + capability: anyCapability("admin.settings.edit"), + input: z.object({ value: z.string() }), + requiresReason: false, + rateLimit: { attempts: 5, windowMs: 120_000 }, + execute: async (commandContext, input) => + ok( + { + value: input.value, + actorId: commandContext.capability.actor.id, + ipAddress: commandContext.ipAddress, + }, + commandContext.correlationId, + ), +}); + +beforeEach(() => { + auditEntries.length = 0; + rateLimitCalls.length = 0; +}); + +describe("executeHousekeepingCommand", () => { + it("accepts a plain request and ignores spoofed server-owned metadata", async () => { + const request = { + commandId: "system.server-action.serializable", + input: { value: "saved" }, + risk: "sensitive", + owner: "people", + capability: { mode: "any", slugs: ["forged.permission"] }, + actor: { id: 999 }, + ipAddress: "198.51.100.9", + rateLimit: { attempts: 999, windowMs: 1 }, + audit: { action: "forged.action", target: "forged-target" }, + }; + + const result = await executeHousekeepingCommand(request); + + expect(result).toMatchObject({ + ok: true, + data: { + value: "saved", + actorId: 71, + ipAddress: "203.0.113.7", + }, + }); + expect(rateLimitCalls).toEqual([ + [ + "housekeeping-command:71:203.0.113.7:system.server-action.serializable", + 5, + 120_000, + ], + ]); + expect(auditEntries).toMatchObject([ + { + userId: 71, + action: "system.server-action.serializable", + target: "system", + domain: "system", + ipAddress: "203.0.113.7", + outcome: "success", + }, + ]); + expect(auditEntries[0]?.correlationId).toBe(result.correlationId); + }); +}); diff --git a/src/actions/housekeeping-command.ts b/src/actions/housekeeping-command.ts new file mode 100644 index 00000000..e699f01a --- /dev/null +++ b/src/actions/housekeeping-command.ts @@ -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> { + 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, + }); +} diff --git a/src/features/housekeeping/foundation/commands/confirmation.test.ts b/src/features/housekeeping/foundation/commands/confirmation.test.ts new file mode 100644 index 00000000..752e504e --- /dev/null +++ b/src/features/housekeeping/foundation/commands/confirmation.test.ts @@ -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, 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", + }); + }); +}); diff --git a/src/features/housekeeping/foundation/commands/confirmation.ts b/src/features/housekeeping/foundation/commands/confirmation.ts new file mode 100644 index 00000000..0ffed083 --- /dev/null +++ b/src/features/housekeeping/foundation/commands/confirmation.ts @@ -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, + reason: string | undefined, + correlationId: string, +): HousekeepingResult { + 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, + ); +} diff --git a/src/features/housekeeping/foundation/commands/dispatcher.test.ts b/src/features/housekeeping/foundation/commands/dispatcher.test.ts new file mode 100644 index 00000000..2ef5dbbb --- /dev/null +++ b/src/features/housekeeping/foundation/commands/dispatcher.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; +import type { HousekeepingCapabilityContext } from "../contracts"; +import { anyCapability, fail, ok } from "../contracts"; +import { dispatchHousekeepingCommand } from "./dispatcher"; +import { + type HousekeepingCommand, + registerHousekeepingCommand, +} from "./registry"; + +const actor = { id: 42, username: "operator", rank: 9 }; + +function capabilityContext( + granted: readonly string[] = ["admin.settings.edit"], +): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +function auditRecorder(events: string[] = []): { + entries: AuditEntry[]; + writer: HousekeepingAuditWriter; +} { + const entries: AuditEntry[] = []; + return { + entries, + writer: { + write: async (entry) => { + entries.push({ ...entry }); + events.push(`audit:${entry.outcome}`); + }, + }, + }; +} + +function dependencies(options?: { + context?: HousekeepingCapabilityContext; + ipAddress?: string; + audit?: HousekeepingAuditWriter; + rateLimit?: ( + key: string, + attempts: number, + windowMs: number, + ) => Promise; +}) { + return { + context: options?.context ?? capabilityContext(), + ipAddress: options?.ipAddress ?? "198.51.100.8", + audit: options?.audit ?? auditRecorder().writer, + rateLimit: options?.rateLimit ?? (async () => true), + }; +} + +function register(command: HousekeepingCommand): void { + registerHousekeepingCommand(command); +} + +function baseCommand( + id: string, + overrides: Pick, "input" | "execute"> & + Partial< + Pick, "risk" | "requiresReason" | "rateLimit"> + >, +): HousekeepingCommand { + return { + id, + owner: "system", + risk: overrides.risk ?? "safe", + capability: anyCapability("admin.settings.edit"), + input: overrides.input, + requiresReason: overrides.requiresReason ?? false, + rateLimit: overrides.rateLimit ?? { attempts: 3, windowMs: 45_000 }, + execute: overrides.execute, + }; +} + +describe("dispatchHousekeepingCommand", () => { + it("returns a typed not-found result for an unknown command", async () => { + const result = await dispatchHousekeepingCommand( + { commandId: "system.missing", input: {} }, + dependencies(), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "NOT_FOUND", + messageKey: "errors.housekeeping.notFound", + }, + }); + expect(result.correlationId).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it("rechecks capability without treating actor rank as authorization", async () => { + let executed = false; + const audit = auditRecorder(); + register( + baseCommand("system.dispatch.denied", { + risk: "sensitive", + input: z.object({}), + execute: async (context) => { + executed = true; + return ok(null, context.correlationId); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.denied", input: {} }, + dependencies({ context: capabilityContext([]), audit: audit.writer }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + }); + expect(executed).toBe(false); + expect(audit.entries.map((entry) => entry.outcome)).toEqual([ + "intent", + "denied", + ]); + expect(audit.entries[0]?.correlationId).toBe(result.correlationId); + expect(audit.entries[1]?.correlationId).toBe(result.correlationId); + }); + + it("rejects invalid input before the command can execute", async () => { + let executed = false; + register( + baseCommand("system.dispatch.invalid-input", { + input: z.object({ count: z.number().int().positive() }), + execute: async (context) => { + executed = true; + return ok(null, context.correlationId); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.invalid-input", input: { count: -1 } }, + dependencies(), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "VALIDATION" }, + }); + expect(executed).toBe(false); + }); + + it("rejects a missing required reason before the command can execute", async () => { + let executed = false; + const audit = auditRecorder(); + register( + baseCommand("system.dispatch.reason", { + risk: "sensitive", + requiresReason: true, + input: z.object({}), + execute: async (context) => { + executed = true; + return ok(null, context.correlationId); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.reason", input: {}, reason: " " }, + dependencies({ audit: audit.writer }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "VALIDATION", + fieldErrors: { reason: ["errors.validation.required"] }, + }, + }); + expect(executed).toBe(false); + expect(audit.entries.map((entry) => entry.outcome)).toEqual([ + "intent", + "denied", + ]); + }); + + it("uses actor, IP, command ID, and the approved command limit", async () => { + const calls: Array<[string, number, number]> = []; + let executed = false; + register( + baseCommand("system.dispatch.rate-limit", { + input: z.object({}), + rateLimit: { attempts: 2, windowMs: 90_000 }, + execute: async (context) => { + executed = true; + return ok(null, context.correlationId); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.rate-limit", input: {} }, + dependencies({ + rateLimit: async (key, attempts, windowMs) => { + calls.push([key, attempts, windowMs]); + return false; + }, + }), + ); + + expect(calls).toEqual([ + [ + "housekeeping-command:42:198.51.100.8:system.dispatch.rate-limit", + 2, + 90_000, + ], + ]); + expect(result).toMatchObject({ + ok: false, + error: { code: "RATE_LIMITED" }, + }); + expect(executed).toBe(false); + }); + + it("executes a safe command with parsed input and writes one success outcome", async () => { + const audit = auditRecorder(); + let receivedCount = 0; + register( + baseCommand("system.dispatch.safe-success", { + input: z.object({ count: z.coerce.number().int().positive() }), + execute: async (_context, input) => { + receivedCount = input.count; + return ok({ doubled: input.count * 2 }, "wrong-command-correlation"); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.safe-success", input: { count: "4" } }, + dependencies({ audit: audit.writer }), + ); + + expect(receivedCount).toBe(4); + expect(result).toMatchObject({ ok: true, data: { doubled: 8 } }); + expect(audit.entries.map((entry) => entry.outcome)).toEqual(["success"]); + expect(audit.entries[0]?.correlationId).toBe(result.correlationId); + expect(result.correlationId).not.toBe("wrong-command-correlation"); + }); + + it("persists sensitive intent before execution and success afterward", async () => { + const events: string[] = []; + const audit = auditRecorder(events); + let executionCorrelation = ""; + register( + baseCommand("system.dispatch.sensitive-success", { + risk: "sensitive", + requiresReason: true, + input: z.object({ enabled: z.boolean() }), + execute: async (context) => { + executionCorrelation = context.correlationId; + events.push("execute"); + return ok({ saved: true }, "untrusted-command-correlation"); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { + commandId: "system.dispatch.sensitive-success", + input: { enabled: true }, + reason: " Planned change ", + }, + dependencies({ audit: audit.writer }), + ); + + expect(events).toEqual(["audit:intent", "execute", "audit:success"]); + expect(audit.entries).toMatchObject([ + { + userId: 42, + action: "system.dispatch.sensitive-success", + target: "system", + domain: "system", + reason: "Planned change", + ipAddress: "198.51.100.8", + outcome: "intent", + }, + { outcome: "success" }, + ]); + expect(executionCorrelation).toBe(result.correlationId); + expect(audit.entries[0]?.correlationId).toBe(result.correlationId); + expect(audit.entries[1]?.correlationId).toBe(result.correlationId); + }); + + it("persists sensitive failure after execution with the same correlation", async () => { + const events: string[] = []; + const audit = auditRecorder(events); + register( + baseCommand("system.dispatch.sensitive-failure", { + risk: "sensitive", + input: z.object({}), + execute: async () => { + events.push("execute"); + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + "wrong-command-correlation", + ); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.sensitive-failure", input: {} }, + dependencies({ audit: audit.writer }), + ); + + expect(events).toEqual(["audit:intent", "execute", "audit:failure"]); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + expect(audit.entries[0]?.correlationId).toBe(result.correlationId); + expect(audit.entries[1]?.correlationId).toBe(result.correlationId); + }); + + it("blocks sensitive execution when intent persistence fails", async () => { + let executed = false; + register( + baseCommand("system.dispatch.intent-failure", { + risk: "sensitive", + input: z.object({}), + execute: async (context) => { + executed = true; + return ok(null, context.correlationId); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.intent-failure", input: {} }, + dependencies({ + audit: { + write: async () => { + throw new Error("audit credentials exposed"); + }, + }, + }), + ); + + expect(executed).toBe(false); + expect(result).toMatchObject({ + ok: false, + error: { + code: "INTERNAL", + messageKey: "errors.housekeeping.internal", + }, + }); + }); + + it("sanitizes unknown exceptions and records a failure outcome", async () => { + const audit = auditRecorder(); + register( + baseCommand("system.dispatch.exception", { + risk: "sensitive", + input: z.object({}), + execute: async () => { + throw new Error("database password=hunter2"); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.exception", input: {} }, + dependencies({ audit: audit.writer }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "INTERNAL", + messageKey: "errors.housekeeping.internal", + }, + }); + expect(JSON.stringify(result)).not.toContain("hunter2"); + expect(audit.entries.map((entry) => entry.outcome)).toEqual([ + "intent", + "failure", + ]); + expect(audit.entries[1]?.correlationId).toBe(result.correlationId); + }); +}); diff --git a/src/features/housekeeping/foundation/commands/dispatcher.ts b/src/features/housekeeping/foundation/commands/dispatcher.ts new file mode 100644 index 00000000..1bd61393 --- /dev/null +++ b/src/features/housekeeping/foundation/commands/dispatcher.ts @@ -0,0 +1,224 @@ +import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; +import { satisfiesCapability } from "../capability-context"; +import { + fail, + type HousekeepingCapabilityContext, + 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, +} from "./registry"; + +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; +} + +export async function dispatchHousekeepingCommand( + request: HousekeepingCommandRequest, + dependencies: HousekeepingCommandDependencies, +): Promise> { + const correlationId = createCorrelationId(); + const command = getHousekeepingCommand(request.commandId); + if (!command) { + return fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId); + } + + const reason = + typeof request.reason === "string" + ? request.reason.normalize("NFC").trim() || undefined + : undefined; + const auditEntry = createAuditEntry( + command, + dependencies.context, + dependencies.ipAddress, + correlationId, + reason, + ); + + if (command.risk === "sensitive") { + try { + await writeIntent(dependencies.audit, auditEntry); + } catch (error) { + return mapUnknownError(error, correlationId); + } + } + + if (!satisfiesCapability(dependencies.context, command.capability)) { + return finishWithAudit( + dependencies.audit, + auditEntry, + "denied", + fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId), + correlationId, + ); + } + + const parsedInput = command.input.safeParse(request.input); + if (!parsedInput.success) { + const fieldErrors: Record = {}; + for (const issue of parsedInput.error.issues) { + const field = String(issue.path[0] ?? "input"); + fieldErrors[field] = ["errors.validation.invalid"]; + } + + return finishWithAudit( + dependencies.audit, + auditEntry, + "denied", + fail( + "VALIDATION", + "errors.housekeeping.validation", + correlationId, + fieldErrors, + ), + correlationId, + ); + } + + const confirmation = confirmHousekeepingCommand( + command, + reason, + correlationId, + ); + if (!confirmation.ok) { + return finishWithAudit( + dependencies.audit, + auditEntry, + "denied", + confirmation, + correlationId, + ); + } + + let allowed: boolean; + try { + allowed = await dependencies.rateLimit( + createRateLimitKey( + command.id, + dependencies.context, + dependencies.ipAddress, + ), + command.rateLimit.attempts, + command.rateLimit.windowMs, + ); + } catch (error) { + return finishWithAudit( + dependencies.audit, + auditEntry, + "failure", + mapUnknownError(error, correlationId), + correlationId, + ); + } + + if (!allowed) { + return finishWithAudit( + dependencies.audit, + auditEntry, + "denied", + fail("RATE_LIMITED", "errors.housekeeping.rateLimited", correlationId), + correlationId, + ); + } + + const commandContext: HousekeepingCommandContext = { + capability: dependencies.context, + correlationId, + ipAddress: dependencies.ipAddress, + }; + let result: HousekeepingResult; + try { + result = await command.execute(commandContext, parsedInput.data); + } catch (error) { + return finishWithAudit( + dependencies.audit, + auditEntry, + "failure", + mapUnknownError(error, correlationId), + correlationId, + ); + } + + const correlatedResult = withCorrelation(result, correlationId); + return finishWithAudit( + dependencies.audit, + auditEntry, + outcomeFor(correlatedResult), + correlatedResult, + correlationId, + ); +} + +function createAuditEntry( + command: HousekeepingCommand, + 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 createRateLimitKey( + commandId: string, + context: HousekeepingCapabilityContext, + ipAddress = "0.0.0.0", +): string { + return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`; +} + +function withCorrelation( + result: HousekeepingResult, + correlationId: string, +): HousekeepingResult { + return { ...result, correlationId }; +} + +function outcomeFor( + result: HousekeepingResult, +): "success" | "failure" | "denied" { + if (result.ok) return "success"; + return result.error.code === "FORBIDDEN" || + result.error.code === "VALIDATION" || + result.error.code === "RATE_LIMITED" + ? "denied" + : "failure"; +} + +async function finishWithAudit( + writer: HousekeepingAuditWriter, + entry: AuditEntry, + outcome: "success" | "failure" | "denied", + result: HousekeepingResult, + correlationId: string, +): Promise> { + try { + await writeOutcome(writer, entry, outcome); + return result; + } catch (error) { + return mapUnknownError(error, correlationId); + } +} diff --git a/src/features/housekeeping/foundation/commands/registry.test.ts b/src/features/housekeeping/foundation/commands/registry.test.ts new file mode 100644 index 00000000..3ab522bf --- /dev/null +++ b/src/features/housekeeping/foundation/commands/registry.test.ts @@ -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"); + }); +}); diff --git a/src/features/housekeeping/foundation/commands/registry.ts b/src/features/housekeeping/foundation/commands/registry.ts new file mode 100644 index 00000000..079cf7ed --- /dev/null +++ b/src/features/housekeeping/foundation/commands/registry.ts @@ -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 { + id: string; + owner: HousekeepingDomainId; + risk: "safe" | "sensitive"; + capability: CapabilityRequirement; + input: z.ZodType; + requiresReason: boolean; + rateLimit: { attempts: number; windowMs: number }; + execute( + context: HousekeepingCommandContext, + input: I, + ): Promise>; +} + +type RegisteredHousekeepingCommand = HousekeepingCommand; + +const approvedOwners = new Set(HOUSEKEEPING_DOMAIN_IDS); +const commands = new Map(); + +export function registerHousekeepingCommand( + command: HousekeepingCommand, +): 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(command: HousekeepingCommand): 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}`); + } +}