From 103e06d20ac08309e7340e10904e1c93a760b212 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 12 May 2026 21:40:28 +0200 Subject: [PATCH] feat(core-audit): withAudit wrapper + Audited brand --- packages/core-audit/src/index.ts | 1 + packages/core-audit/src/with-audit.test.ts | 36 ++++++++++++++++++++++ packages/core-audit/src/with-audit.ts | 34 ++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 packages/core-audit/src/with-audit.test.ts create mode 100644 packages/core-audit/src/with-audit.ts diff --git a/packages/core-audit/src/index.ts b/packages/core-audit/src/index.ts index a76b85e..ae24c20 100644 --- a/packages/core-audit/src/index.ts +++ b/packages/core-audit/src/index.ts @@ -28,3 +28,4 @@ export { auditProcedure, type AdminTrpcUser, } from "./integrations/api/procedures"; +export { withAudit, type Audited } from "./with-audit"; diff --git a/packages/core-audit/src/with-audit.test.ts b/packages/core-audit/src/with-audit.test.ts new file mode 100644 index 0000000..2dc1954 --- /dev/null +++ b/packages/core-audit/src/with-audit.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, expectTypeOf, vi } from "vitest"; +import { withAudit, type Audited } from "@/with-audit"; +import type { IAuditLog } from "@/audit-log.interface"; + +function makeAuditLog(): IAuditLog { + return { + record: vi.fn().mockResolvedValue(undefined), + eraseSubject: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("withAudit", () => { + it("returns an Audited", () => { + const auditLog = makeAuditLog(); + const fn = async (input: { id: string }) => ({ ok: true }); + const wrapped = withAudit(auditLog, fn); + expectTypeOf(wrapped).toMatchTypeOf>(); + }); + + it("passes input and output through unchanged", async () => { + const auditLog = makeAuditLog(); + const fn = async (input: { id: string }) => ({ ok: true, id: input.id }); + const wrapped = withAudit(auditLog, fn); + const result = await wrapped({ id: "abc" }); + expect(result).toEqual({ ok: true, id: "abc" }); + }); + + it("propagates errors", async () => { + const auditLog = makeAuditLog(); + const err = new Error("boom"); + const wrapped = withAudit(auditLog, async () => { + throw err; + }); + await expect(wrapped()).rejects.toBe(err); + }); +}); diff --git a/packages/core-audit/src/with-audit.ts b/packages/core-audit/src/with-audit.ts new file mode 100644 index 0000000..a521816 --- /dev/null +++ b/packages/core-audit/src/with-audit.ts @@ -0,0 +1,34 @@ +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Phantom-type brand attached at wrap time by `withAudit`. The conformance + * system uses this as the type-level seam for mutating use cases that + * declare `audits: [...]` in their manifest — without `__audited`, the + * binding is not assignable to `ProductionUseCase` when M demands + * it. + */ +export type Audited = F & { readonly __audited: true }; + +/** + * Use-case wrapper applied at DI bind time. In milestone i this is a + * brand-only attachment: it does not yet automatically call `auditLog.record`. + * Use cases continue to call `auditLog.record(...)` in their own bodies; the + * wrapper exists to make "binding was bound through the audit-aware path" + * type-checkable at compile time. + * + * A future story may move auditing logic out of factory bodies and into the + * wrapper itself (driven by manifest declarations) — but that requires the + * manifest's `audits[]` entries to fully specify what gets recorded, which + * is out of scope here. + */ +export function withAudit( + // The auditLog is part of the signature for two reasons: (1) callers must + // pass it at bind time, ensuring the dep is available, and (2) future + // versions of this wrapper will use it to emit audit events from the + // declarative manifest entry directly. + auditLog: IAuditLog, + fn: (...args: Args) => Promise, +): Audited<(...args: Args) => Promise> { + void auditLog; + return fn as Audited<(...args: Args) => Promise>; +}