refactor(housekeeping): stabilize service contracts
This commit is contained in:
1 parent
67cb4b488d
commit
b7edbca043
10 files changed
+145
-28
No files matched your search
@@ -13,6 +13,7 @@ import {
|
|||||||
type HousekeepingSearchResult,
|
type HousekeepingSearchResult,
|
||||||
type HousekeepingWidgetDefinition,
|
type HousekeepingWidgetDefinition,
|
||||||
type HousekeepingWorkItem,
|
type HousekeepingWorkItem,
|
||||||
|
mapUnknownError,
|
||||||
ok,
|
ok,
|
||||||
} from ".";
|
} from ".";
|
||||||
|
|
||||||
@@ -25,18 +26,32 @@ const workItem = {
|
|||||||
domain: "people",
|
domain: "people",
|
||||||
capability,
|
capability,
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
|
priority: "high",
|
||||||
|
ageMs: 60_000,
|
||||||
|
state: "open",
|
||||||
occurredAt: "2026-08-24T12:00:00.000Z",
|
occurredAt: "2026-08-24T12:00:00.000Z",
|
||||||
titleKey: "pages.housekeeping.items.ticket",
|
titleKey: "pages.housekeeping.items.ticket",
|
||||||
context: { ticketId: "42" },
|
context: { ticketId: "42" },
|
||||||
href: "/ase-next/people/tickets/42",
|
href: "/ase/people/tickets/42",
|
||||||
freshness: "fresh",
|
freshness: "fresh",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "people.ticket.open",
|
||||||
|
labelKey: "pages.housekeeping.items.ticket.open",
|
||||||
|
commandId: "people.ticket.open",
|
||||||
|
capability,
|
||||||
|
},
|
||||||
|
],
|
||||||
} satisfies HousekeepingWorkItem;
|
} satisfies HousekeepingWorkItem;
|
||||||
|
|
||||||
const searchResult = {
|
const searchResult = {
|
||||||
id: "user-42",
|
id: "user-42",
|
||||||
domain: "people",
|
domain: "people",
|
||||||
|
type: "entity",
|
||||||
title: "operator",
|
title: "operator",
|
||||||
href: "/ase-next/people/users/42",
|
description: "A Housekeeping operator",
|
||||||
|
href: "/ase/people/users/42",
|
||||||
|
capability,
|
||||||
} satisfies HousekeepingSearchResult;
|
} satisfies HousekeepingSearchResult;
|
||||||
|
|
||||||
const searchProvider: HousekeepingSearchProvider = {
|
const searchProvider: HousekeepingSearchProvider = {
|
||||||
@@ -87,7 +102,7 @@ const manifest: HousekeepingDomainManifest = {
|
|||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||||
iconId: "users",
|
iconId: "users",
|
||||||
canonicalHref: "/ase-next/people",
|
canonicalHref: "/ase/people",
|
||||||
capability,
|
capability,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [searchProvider],
|
searchProviders: [searchProvider],
|
||||||
@@ -96,19 +111,47 @@ const manifest: HousekeepingDomainManifest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("housekeeping foundation contracts", () => {
|
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({
|
expect(ok({ count: 2 }, "corr-1")).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { count: 2 },
|
data: { count: 2 },
|
||||||
correlationId: "corr-1",
|
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,
|
ok: false,
|
||||||
error: { code: "CAPABILITY_DENIED" },
|
error: {
|
||||||
|
code,
|
||||||
|
messageKey: `errors.housekeeping.${code.toLowerCase()}`,
|
||||||
|
fieldErrors: { name: ["errors.validation.required"] },
|
||||||
|
},
|
||||||
correlationId: "corr-2",
|
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", () => {
|
it("rejects empty capability requirements", () => {
|
||||||
expect(() => anyCapability()).toThrow("capability requirement is empty");
|
expect(() => anyCapability()).toThrow("capability requirement is empty");
|
||||||
expect(() => allCapabilities()).toThrow("capability requirement is empty");
|
expect(() => allCapabilities()).toThrow("capability requirement is empty");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { HousekeepingSearchProvider } from "./search";
|
|||||||
import type { HousekeepingWidgetDefinition } from "./widget";
|
import type { HousekeepingWidgetDefinition } from "./widget";
|
||||||
|
|
||||||
export type HousekeepingSurface = "preview" | "canonical";
|
export type HousekeepingSurface = "preview" | "canonical";
|
||||||
export type CanonicalHousekeepingHref = `/ase${string}`;
|
export type CanonicalHousekeepingHref = "/ase" | `/ase/${string}`;
|
||||||
export interface HousekeepingRouteDefinition {
|
export interface HousekeepingRouteDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
|||||||
@@ -3,12 +3,25 @@ import type {
|
|||||||
CapabilityRequirement,
|
CapabilityRequirement,
|
||||||
HousekeepingCapabilityContext,
|
HousekeepingCapabilityContext,
|
||||||
} from "./capability";
|
} from "./capability";
|
||||||
|
import type { CanonicalHousekeepingHref } from "./domain";
|
||||||
import type { HousekeepingResult } from "./result";
|
import type { HousekeepingResult } from "./result";
|
||||||
|
|
||||||
export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical";
|
export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical";
|
||||||
export type HousekeepingWorkItemFreshness = "fresh" | "stale";
|
export type HousekeepingWorkItemFreshness = "fresh" | "stale";
|
||||||
|
export type HousekeepingWorkItemPriority =
|
||||||
|
| "low"
|
||||||
|
| "normal"
|
||||||
|
| "high"
|
||||||
|
| "critical";
|
||||||
export type HousekeepingInboxSourceAvailability = "available" | "unavailable";
|
export type HousekeepingInboxSourceAvailability = "available" | "unavailable";
|
||||||
|
|
||||||
|
export interface HousekeepingWorkItemAction {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
commandId: string;
|
||||||
|
capability: CapabilityRequirement;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HousekeepingWorkItem {
|
export interface HousekeepingWorkItem {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -16,11 +29,15 @@ export interface HousekeepingWorkItem {
|
|||||||
domain: HousekeepingDomainId;
|
domain: HousekeepingDomainId;
|
||||||
capability: CapabilityRequirement;
|
capability: CapabilityRequirement;
|
||||||
severity: HousekeepingWorkItemSeverity;
|
severity: HousekeepingWorkItemSeverity;
|
||||||
|
priority: HousekeepingWorkItemPriority;
|
||||||
|
ageMs: number;
|
||||||
|
state: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
context?: Readonly<Record<string, unknown>>;
|
context?: Readonly<Record<string, unknown>>;
|
||||||
href: string;
|
href: CanonicalHousekeepingHref;
|
||||||
freshness: HousekeepingWorkItemFreshness;
|
freshness: HousekeepingWorkItemFreshness;
|
||||||
|
actions: readonly HousekeepingWorkItemAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HousekeepingInboxSourceResult {
|
export interface HousekeepingInboxSourceResult {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { createCorrelationId } from "../correlation";
|
||||||
export {
|
export {
|
||||||
allCapabilities,
|
allCapabilities,
|
||||||
anyCapability,
|
anyCapability,
|
||||||
@@ -17,20 +18,25 @@ export type {
|
|||||||
HousekeepingInboxSourceAvailability,
|
HousekeepingInboxSourceAvailability,
|
||||||
HousekeepingInboxSourceResult,
|
HousekeepingInboxSourceResult,
|
||||||
HousekeepingWorkItem,
|
HousekeepingWorkItem,
|
||||||
|
HousekeepingWorkItemAction,
|
||||||
HousekeepingWorkItemFreshness,
|
HousekeepingWorkItemFreshness,
|
||||||
|
HousekeepingWorkItemPriority,
|
||||||
HousekeepingWorkItemSeverity,
|
HousekeepingWorkItemSeverity,
|
||||||
} from "./inbox";
|
} from "./inbox";
|
||||||
export type { HousekeepingQuery } from "./query";
|
export type { HousekeepingQuery } from "./query";
|
||||||
export {
|
export {
|
||||||
fail,
|
fail,
|
||||||
|
type HousekeepingError,
|
||||||
type HousekeepingErrorCode,
|
type HousekeepingErrorCode,
|
||||||
type HousekeepingResult,
|
type HousekeepingResult,
|
||||||
|
mapUnknownError,
|
||||||
ok,
|
ok,
|
||||||
} from "./result";
|
} from "./result";
|
||||||
export type {
|
export type {
|
||||||
HousekeepingSearchInput,
|
HousekeepingSearchInput,
|
||||||
HousekeepingSearchProvider,
|
HousekeepingSearchProvider,
|
||||||
HousekeepingSearchResult,
|
HousekeepingSearchResult,
|
||||||
|
HousekeepingSearchResultType,
|
||||||
} from "./search";
|
} from "./search";
|
||||||
export type {
|
export type {
|
||||||
HousekeepingWidgetDefinition,
|
HousekeepingWidgetDefinition,
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
export type HousekeepingErrorCode =
|
export type HousekeepingErrorCode =
|
||||||
| "VALIDATION_FAILED"
|
| "UNAUTHENTICATED"
|
||||||
| "AUTHENTICATION_REQUIRED"
|
| "FORBIDDEN"
|
||||||
| "CAPABILITY_DENIED"
|
| "VALIDATION"
|
||||||
| "NOT_FOUND"
|
| "NOT_FOUND"
|
||||||
| "CONFLICT"
|
| "CONFLICT"
|
||||||
|
| "RATE_LIMITED"
|
||||||
| "DEPENDENCY_UNAVAILABLE"
|
| "DEPENDENCY_UNAVAILABLE"
|
||||||
| "PARTIAL_RESULT"
|
| "TIMEOUT"
|
||||||
| "INTERNAL_ERROR";
|
| "INTERNAL";
|
||||||
|
|
||||||
|
export interface HousekeepingError {
|
||||||
|
code: HousekeepingErrorCode;
|
||||||
|
messageKey: string;
|
||||||
|
fieldErrors?: Readonly<Record<string, readonly string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
export type HousekeepingResult<T> =
|
export type HousekeepingResult<T> =
|
||||||
| { ok: true; data: T; correlationId: string }
|
| { ok: true; data: T; correlationId: string }
|
||||||
| {
|
| { ok: false; error: HousekeepingError; correlationId: string };
|
||||||
ok: false;
|
|
||||||
error: { code: HousekeepingErrorCode };
|
|
||||||
correlationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
||||||
return { ok: true, data, correlationId };
|
return { ok: true, data, correlationId };
|
||||||
@@ -22,7 +25,24 @@ export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
|||||||
|
|
||||||
export function fail(
|
export function fail(
|
||||||
code: HousekeepingErrorCode,
|
code: HousekeepingErrorCode,
|
||||||
|
messageKey: string,
|
||||||
correlationId: string,
|
correlationId: string,
|
||||||
|
fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||||
): HousekeepingResult<never> {
|
): HousekeepingResult<never> {
|
||||||
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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CapabilityRequirement,
|
CapabilityRequirement,
|
||||||
HousekeepingCapabilityContext,
|
HousekeepingCapabilityContext,
|
||||||
} from "./capability";
|
} from "./capability";
|
||||||
|
import type { CanonicalHousekeepingHref } from "./domain";
|
||||||
import type { HousekeepingResult } from "./result";
|
import type { HousekeepingResult } from "./result";
|
||||||
|
|
||||||
export interface HousekeepingSearchInput {
|
export interface HousekeepingSearchInput {
|
||||||
@@ -10,11 +11,16 @@ export interface HousekeepingSearchInput {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HousekeepingSearchResultType = "navigation" | "entity" | "command";
|
||||||
|
|
||||||
export interface HousekeepingSearchResult {
|
export interface HousekeepingSearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
domain: HousekeepingDomainId;
|
domain: HousekeepingDomainId;
|
||||||
|
type: HousekeepingSearchResultType;
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
description?: string;
|
||||||
|
href: CanonicalHousekeepingHref;
|
||||||
|
capability: CapabilityRequirement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HousekeepingSearchProvider {
|
export interface HousekeepingSearchProvider {
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ export interface HousekeepingWidgetDefinition {
|
|||||||
kind: HousekeepingWidgetKind;
|
kind: HousekeepingWidgetKind;
|
||||||
load(
|
load(
|
||||||
context: HousekeepingCapabilityContext,
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<HousekeepingResult<unknown>>;
|
): Promise<HousekeepingResult<unknown>>;
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function createCorrelationId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../migration/types";
|
} from "../migration/types";
|
||||||
import {
|
import {
|
||||||
anyCapability,
|
anyCapability,
|
||||||
|
type CanonicalHousekeepingHref,
|
||||||
type CapabilityRequirement,
|
type CapabilityRequirement,
|
||||||
type HousekeepingDomainManifest,
|
type HousekeepingDomainManifest,
|
||||||
type HousekeepingInboxSource,
|
type HousekeepingInboxSource,
|
||||||
@@ -232,7 +233,7 @@ describe("housekeeping registry", () => {
|
|||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase/people/users" as `/ase${string}`,
|
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||||
capability: anyCapability(PERMS.USERS_VIEW),
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -253,13 +254,13 @@ describe("housekeeping registry", () => {
|
|||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase/people/users" as `/ase${string}`,
|
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||||
capability: anyCapability(PERMS.USERS_VIEW),
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "staff",
|
id: "staff",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase/people/users" as `/ase${string}`,
|
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||||
capability: anyCapability(PERMS.USERS_VIEW),
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -272,7 +273,7 @@ describe("housekeeping registry", () => {
|
|||||||
const route = {
|
const route = {
|
||||||
id: "users",
|
id: "users",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase/people/users" as `/ase${string}`,
|
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||||
capability: anyCapability(PERMS.USERS_VIEW),
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ describe("housekeeping registry", () => {
|
|||||||
createHousekeepingRegistry([
|
createHousekeepingRegistry([
|
||||||
{
|
{
|
||||||
...manifest("people"),
|
...manifest("people"),
|
||||||
routes: [{ ...route, href: " " as `/ase${string}` }],
|
routes: [{ ...route, href: " " as CanonicalHousekeepingHref }],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).toThrow("empty route href");
|
).toThrow("empty route href");
|
||||||
@@ -484,7 +485,7 @@ describe("housekeeping registry", () => {
|
|||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase/people/users" as `/ase${string}`,
|
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||||
capability: { mode: "any", slugs: [] },
|
capability: { mode: "any", slugs: [] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -497,7 +498,7 @@ describe("housekeeping registry", () => {
|
|||||||
const route = {
|
const route = {
|
||||||
id: "shared",
|
id: "shared",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase/shared" as `/ase${string}`,
|
href: "/ase/shared" as CanonicalHousekeepingHref,
|
||||||
capability: anyCapability(PERMS.USERS_VIEW),
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,7 +508,10 @@ describe("housekeeping registry", () => {
|
|||||||
{
|
{
|
||||||
...manifest("content"),
|
...manifest("content"),
|
||||||
routes: [
|
routes: [
|
||||||
{ ...route, href: "/ase/content/shared" as `/ase${string}` },
|
{
|
||||||
|
...route,
|
||||||
|
href: "/ase/content/shared" as CanonicalHousekeepingHref,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in new issue
Block a user