From b7edbca043b8321701abc1c49fabbfe1320121a9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 21:04:42 +0200 Subject: [PATCH] refactor(housekeeping): stabilize service contracts --- .../foundation/contracts/contracts.test.ts | 55 +++++++++++++++++-- .../foundation/contracts/domain.ts | 2 +- .../foundation/contracts/inbox.ts | 19 ++++++- .../foundation/contracts/index.ts | 6 ++ .../foundation/contracts/result.ts | 42 ++++++++++---- .../foundation/contracts/search.ts | 8 ++- .../foundation/contracts/widget.ts | 1 + .../foundation/correlation.test.ts | 17 ++++++ .../housekeeping/foundation/correlation.ts | 3 + .../housekeeping/foundation/registry.test.ts | 20 ++++--- 10 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 src/features/housekeeping/foundation/correlation.test.ts create mode 100644 src/features/housekeeping/foundation/correlation.ts diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts index dfc53c9906..b5ae44d789 100644 --- a/src/features/housekeeping/foundation/contracts/contracts.test.ts +++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts @@ -13,6 +13,7 @@ import { type HousekeepingSearchResult, type HousekeepingWidgetDefinition, type HousekeepingWorkItem, + mapUnknownError, ok, } from "."; @@ -25,18 +26,32 @@ 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-next/people/tickets/42", + href: "/ase/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", - href: "/ase-next/people/users/42", + description: "A Housekeeping operator", + href: "/ase/people/users/42", + capability, } satisfies HousekeepingSearchResult; const searchProvider: HousekeepingSearchProvider = { @@ -87,7 +102,7 @@ const manifest: HousekeepingDomainManifest = { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - canonicalHref: "/ase-next/people", + canonicalHref: "/ase/people", capability, routes: [], searchProviders: [searchProvider], @@ -96,19 +111,47 @@ const manifest: HousekeepingDomainManifest = { }; describe("housekeeping foundation contracts", () => { - it("creates typed success and error results", () => { + it("preserves correlation IDs for successful results", () => { expect(ok({ count: 2 }, "corr-1")).toEqual({ ok: true, data: { count: 2 }, correlationId: "corr-1", }); - expect(fail("CAPABILITY_DENIED", "corr-2")).toEqual({ + }); + + 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({ ok: false, - error: { code: "CAPABILITY_DENIED" }, + error: { + code, + messageKey: `errors.housekeeping.${code.toLowerCase()}`, + fieldErrors: { name: ["errors.validation.required"] }, + }, correlationId: "corr-2", }); }); + it("sanitizes unknown errors with the stable internal taxonomy", () => { + expect(mapUnknownError(new Error("database password exposed"))).toEqual({ + code: "INTERNAL", + messageKey: "errors.housekeeping.internal", + }); + }); + it("rejects empty capability requirements", () => { expect(() => anyCapability()).toThrow("capability requirement is empty"); expect(() => allCapabilities()).toThrow("capability requirement is empty"); diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts index a4c6f9ebe3..3341f425e0 100644 --- a/src/features/housekeeping/foundation/contracts/domain.ts +++ b/src/features/housekeeping/foundation/contracts/domain.ts @@ -5,7 +5,7 @@ import type { HousekeepingSearchProvider } from "./search"; import type { HousekeepingWidgetDefinition } from "./widget"; export type HousekeepingSurface = "preview" | "canonical"; -export type CanonicalHousekeepingHref = `/ase${string}`; +export type CanonicalHousekeepingHref = "/ase" | `/ase/${string}`; export interface HousekeepingRouteDefinition { id: string; labelKey: string; diff --git a/src/features/housekeeping/foundation/contracts/inbox.ts b/src/features/housekeeping/foundation/contracts/inbox.ts index 72bc4e8999..29cae98692 100644 --- a/src/features/housekeeping/foundation/contracts/inbox.ts +++ b/src/features/housekeeping/foundation/contracts/inbox.ts @@ -3,12 +3,25 @@ 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; @@ -16,11 +29,15 @@ export interface HousekeepingWorkItem { domain: HousekeepingDomainId; capability: CapabilityRequirement; severity: HousekeepingWorkItemSeverity; + priority: HousekeepingWorkItemPriority; + ageMs: number; + state: string; occurredAt: string; titleKey: string; context?: Readonly>; - href: string; + href: CanonicalHousekeepingHref; freshness: HousekeepingWorkItemFreshness; + actions: readonly HousekeepingWorkItemAction[]; } export interface HousekeepingInboxSourceResult { diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts index 5f0e35ca05..2aeee30922 100644 --- a/src/features/housekeeping/foundation/contracts/index.ts +++ b/src/features/housekeeping/foundation/contracts/index.ts @@ -1,3 +1,4 @@ +export { createCorrelationId } from "../correlation"; export { allCapabilities, anyCapability, @@ -17,20 +18,25 @@ export type { HousekeepingInboxSourceAvailability, HousekeepingInboxSourceResult, HousekeepingWorkItem, + HousekeepingWorkItemAction, HousekeepingWorkItemFreshness, + HousekeepingWorkItemPriority, HousekeepingWorkItemSeverity, } from "./inbox"; export type { HousekeepingQuery } from "./query"; export { fail, + type HousekeepingError, type HousekeepingErrorCode, type HousekeepingResult, + mapUnknownError, ok, } from "./result"; export type { HousekeepingSearchInput, HousekeepingSearchProvider, HousekeepingSearchResult, + HousekeepingSearchResultType, } from "./search"; export type { HousekeepingWidgetDefinition, diff --git a/src/features/housekeeping/foundation/contracts/result.ts b/src/features/housekeeping/foundation/contracts/result.ts index e838ad5925..19e878e50f 100644 --- a/src/features/housekeeping/foundation/contracts/result.ts +++ b/src/features/housekeeping/foundation/contracts/result.ts @@ -1,20 +1,23 @@ export type HousekeepingErrorCode = - | "VALIDATION_FAILED" - | "AUTHENTICATION_REQUIRED" - | "CAPABILITY_DENIED" + | "UNAUTHENTICATED" + | "FORBIDDEN" + | "VALIDATION" | "NOT_FOUND" | "CONFLICT" + | "RATE_LIMITED" | "DEPENDENCY_UNAVAILABLE" - | "PARTIAL_RESULT" - | "INTERNAL_ERROR"; + | "TIMEOUT" + | "INTERNAL"; + +export interface HousekeepingError { + code: HousekeepingErrorCode; + messageKey: string; + fieldErrors?: Readonly>; +} export type HousekeepingResult = | { ok: true; data: T; correlationId: string } - | { - ok: false; - error: { code: HousekeepingErrorCode }; - correlationId: string; - }; + | { ok: false; error: HousekeepingError; correlationId: string }; export function ok(data: T, correlationId: string): HousekeepingResult { return { ok: true, data, correlationId }; @@ -22,7 +25,24 @@ export function ok(data: T, correlationId: string): HousekeepingResult { export function fail( code: HousekeepingErrorCode, + messageKey: string, correlationId: string, + fieldErrors?: Readonly>, ): HousekeepingResult { - return { ok: false, error: { code }, correlationId }; + return { + ok: false, + error: { + code, + messageKey, + ...(fieldErrors === undefined ? {} : { fieldErrors }), + }, + correlationId, + }; +} + +export function mapUnknownError(_error: unknown): HousekeepingError { + return { + code: "INTERNAL", + messageKey: "errors.housekeeping.internal", + }; } diff --git a/src/features/housekeeping/foundation/contracts/search.ts b/src/features/housekeeping/foundation/contracts/search.ts index b12ca33495..9e9038de4e 100644 --- a/src/features/housekeeping/foundation/contracts/search.ts +++ b/src/features/housekeeping/foundation/contracts/search.ts @@ -3,6 +3,7 @@ import type { CapabilityRequirement, HousekeepingCapabilityContext, } from "./capability"; +import type { CanonicalHousekeepingHref } from "./domain"; import type { HousekeepingResult } from "./result"; export interface HousekeepingSearchInput { @@ -10,11 +11,16 @@ export interface HousekeepingSearchInput { limit: number; } +export type HousekeepingSearchResultType = "navigation" | "entity" | "command"; + export interface HousekeepingSearchResult { id: string; domain: HousekeepingDomainId; + type: HousekeepingSearchResultType; title: string; - href: string; + description?: string; + href: CanonicalHousekeepingHref; + capability: CapabilityRequirement; } export interface HousekeepingSearchProvider { diff --git a/src/features/housekeeping/foundation/contracts/widget.ts b/src/features/housekeeping/foundation/contracts/widget.ts index 3fee43234d..5911cb4fbc 100644 --- a/src/features/housekeeping/foundation/contracts/widget.ts +++ b/src/features/housekeeping/foundation/contracts/widget.ts @@ -14,5 +14,6 @@ export interface HousekeepingWidgetDefinition { kind: HousekeepingWidgetKind; load( context: HousekeepingCapabilityContext, + signal: AbortSignal, ): Promise>; } diff --git a/src/features/housekeeping/foundation/correlation.test.ts b/src/features/housekeeping/foundation/correlation.test.ts new file mode 100644 index 0000000000..f2c8a773c3 --- /dev/null +++ b/src/features/housekeeping/foundation/correlation.test.ts @@ -0,0 +1,17 @@ +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()); + }); +}); diff --git a/src/features/housekeeping/foundation/correlation.ts b/src/features/housekeeping/foundation/correlation.ts new file mode 100644 index 0000000000..d01c911300 --- /dev/null +++ b/src/features/housekeeping/foundation/correlation.ts @@ -0,0 +1,3 @@ +export function createCorrelationId(): string { + return crypto.randomUUID(); +} diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 50c9147f3e..209815bec5 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -8,6 +8,7 @@ import { } from "../migration/types"; import { anyCapability, + type CanonicalHousekeepingHref, type CapabilityRequirement, type HousekeepingDomainManifest, type HousekeepingInboxSource, @@ -232,7 +233,7 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/ase/people/users" as `/ase${string}`, + href: "/ase/people/users" as CanonicalHousekeepingHref, capability: anyCapability(PERMS.USERS_VIEW), }, { @@ -253,13 +254,13 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/ase/people/users" as `/ase${string}`, + href: "/ase/people/users" as CanonicalHousekeepingHref, capability: anyCapability(PERMS.USERS_VIEW), }, { id: "staff", labelKey: "pages.housekeeping.domains.people.title", - href: "/ase/people/users" as `/ase${string}`, + href: "/ase/people/users" as CanonicalHousekeepingHref, capability: anyCapability(PERMS.USERS_VIEW), }, ], @@ -272,7 +273,7 @@ describe("housekeeping registry", () => { const route = { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/ase/people/users" as `/ase${string}`, + href: "/ase/people/users" as CanonicalHousekeepingHref, capability: anyCapability(PERMS.USERS_VIEW), }; @@ -285,7 +286,7 @@ describe("housekeeping registry", () => { createHousekeepingRegistry([ { ...manifest("people"), - routes: [{ ...route, href: " " as `/ase${string}` }], + routes: [{ ...route, href: " " as CanonicalHousekeepingHref }], }, ]), ).toThrow("empty route href"); @@ -484,7 +485,7 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/ase/people/users" as `/ase${string}`, + href: "/ase/people/users" as CanonicalHousekeepingHref, capability: { mode: "any", slugs: [] }, }, ], @@ -497,7 +498,7 @@ describe("housekeeping registry", () => { const route = { id: "shared", labelKey: "pages.housekeeping.domains.people.title", - href: "/ase/shared" as `/ase${string}`, + href: "/ase/shared" as CanonicalHousekeepingHref, capability: anyCapability(PERMS.USERS_VIEW), }; @@ -507,7 +508,10 @@ describe("housekeeping registry", () => { { ...manifest("content"), routes: [ - { ...route, href: "/ase/content/shared" as `/ase${string}` }, + { + ...route, + href: "/ase/content/shared" as CanonicalHousekeepingHref, + }, ], }, ]),