feat(housekeeping): compose operational inbox
This commit is contained in:
1 parent
bcb0ca977f
commit
300ac0ef95
13 files changed
+1594
-7
No files matched your search
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import { createHousekeepingInboxService, INBOX_POLICY } from "./inbox-service";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
function item(
|
||||
itemId: string,
|
||||
options: Partial<HousekeepingWorkItem> = {},
|
||||
): HousekeepingWorkItem {
|
||||
return {
|
||||
sourceId: "people.queue",
|
||||
itemId,
|
||||
deduplicationKey: `people.queue:${itemId}`,
|
||||
domain: "people",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
severity: "info",
|
||||
priority: "normal",
|
||||
ageMs: 1_000,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-30T10:00:00.000Z",
|
||||
titleKey: "pages.housekeeping.items.test",
|
||||
href: "/ase/people/users",
|
||||
freshness: "fresh",
|
||||
actions: [],
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function source(
|
||||
id: string,
|
||||
permission: string,
|
||||
getItems: HousekeepingInboxSource["getItems"],
|
||||
owner: "people" | "content" = "people",
|
||||
): HousekeepingInboxSource {
|
||||
return {
|
||||
id,
|
||||
owner,
|
||||
capability: anyCapability(permission),
|
||||
getItems,
|
||||
};
|
||||
}
|
||||
|
||||
function manifest(
|
||||
id: "people" | "content",
|
||||
permission: string,
|
||||
inboxSources: readonly HousekeepingInboxSource[],
|
||||
): HousekeepingDomainManifest {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.domains.${id}.title`,
|
||||
descriptionKey: `pages.housekeeping.domains.${id}.description`,
|
||||
iconId: id === "people" ? "users" : "file-text",
|
||||
canonicalHref: `/ase/${id}`,
|
||||
capability: anyCapability(permission),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources,
|
||||
widgets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function registry(
|
||||
...domains: readonly HousekeepingDomainManifest[]
|
||||
): HousekeepingRegistry {
|
||||
return { domains };
|
||||
}
|
||||
|
||||
describe("Housekeeping inbox service", () => {
|
||||
it("locks timeout, cap, and deduplication policy", () => {
|
||||
expect(INBOX_POLICY).toEqual({
|
||||
timeoutMs: 2_000,
|
||||
combinedLimit: 200,
|
||||
dedupe: "sourceId:itemId",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters domain and source capabilities before invoking sources", async () => {
|
||||
const permitted = vi.fn(async () =>
|
||||
ok({ availability: "available" as const, items: [] }, "permitted"),
|
||||
);
|
||||
const forbiddenSource = vi.fn(async () =>
|
||||
ok({ availability: "available" as const, items: [] }, "source"),
|
||||
);
|
||||
const forbiddenDomain = vi.fn(async () =>
|
||||
ok({ availability: "available" as const, items: [] }, "domain"),
|
||||
);
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.ready", PERMS.USERS_VIEW, permitted),
|
||||
source("people.bans", PERMS.BANS_VIEW, forbiddenSource),
|
||||
]),
|
||||
manifest("content", PERMS.NEWS_VIEW, [
|
||||
source("content.users", PERMS.USERS_VIEW, forbiddenDomain, "content"),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-capabilities",
|
||||
});
|
||||
|
||||
await service(context([PERMS.USERS_VIEW]));
|
||||
expect(permitted).toHaveBeenCalledOnce();
|
||||
expect(forbiddenSource).not.toHaveBeenCalled();
|
||||
expect(forbiddenDomain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates by source/item and orders by priority then oldest age", async () => {
|
||||
const items = [
|
||||
item("normal", { priority: "normal", ageMs: 900 }),
|
||||
item("high-new", { priority: "high", ageMs: 100 }),
|
||||
item("critical", { priority: "critical", ageMs: 10 }),
|
||||
item("high-old", { priority: "high", ageMs: 1_000 }),
|
||||
item("low", { priority: "low", ageMs: 5_000 }),
|
||||
item("high-old", { priority: "high", ageMs: 999 }),
|
||||
];
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.queue", PERMS.USERS_VIEW, async () =>
|
||||
ok({ availability: "available", items }, "ordered"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-order",
|
||||
});
|
||||
|
||||
const response = await service(context([PERMS.USERS_VIEW]));
|
||||
expect(response.items.map((entry) => entry.itemId)).toEqual([
|
||||
"critical",
|
||||
"high-old",
|
||||
"high-new",
|
||||
"normal",
|
||||
"low",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters item capabilities before enforcing the 200-item cap", async () => {
|
||||
const items = Array.from({ length: 230 }, (_, index) =>
|
||||
item(String(index), {
|
||||
capability: anyCapability(
|
||||
index < 20 ? PERMS.BANS_VIEW : PERMS.USERS_VIEW,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.queue", PERMS.USERS_VIEW, async () =>
|
||||
ok({ availability: "available", items }, "capped"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-cap",
|
||||
});
|
||||
|
||||
const response = await service(context([PERMS.USERS_VIEW]));
|
||||
expect(response.items).toHaveLength(200);
|
||||
expect(response.items[0]?.itemId).toBe("20");
|
||||
expect(response.items.at(-1)?.itemId).toBe("219");
|
||||
});
|
||||
|
||||
it("returns successful items with stable timeout and source errors", async () => {
|
||||
let timeoutSignal: AbortSignal | undefined;
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: registry(
|
||||
manifest("people", PERMS.USERS_VIEW, [
|
||||
source("people.timeout", PERMS.USERS_VIEW, (_context, signal) => {
|
||||
timeoutSignal = signal;
|
||||
return new Promise(() => undefined);
|
||||
}),
|
||||
source("people.failed", PERMS.USERS_VIEW, async () =>
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"failed",
|
||||
),
|
||||
),
|
||||
source("people.ready", PERMS.USERS_VIEW, async () =>
|
||||
ok(
|
||||
{
|
||||
availability: "available",
|
||||
items: [item("ready", { sourceId: "people.ready" })],
|
||||
},
|
||||
"ready",
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
createCorrelationId: () => "inbox-partial",
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
const response = await service(context([PERMS.USERS_VIEW]));
|
||||
expect(response.items.map((entry) => entry.itemId)).toEqual(["ready"]);
|
||||
expect(response.errors).toEqual([
|
||||
{ sourceId: "people.timeout", code: "TIMEOUT" },
|
||||
{ sourceId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" },
|
||||
]);
|
||||
expect(timeoutSignal?.aborted).toBe(true);
|
||||
expect(response.correlationId).toBe("inbox-partial");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import "server-only";
|
||||
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import { isSafeHousekeepingHref } from "../housekeeping-href";
|
||||
import {
|
||||
type HousekeepingProvider,
|
||||
runProvider,
|
||||
} from "../providers/run-provider";
|
||||
import {
|
||||
createHousekeepingRegistry,
|
||||
type HousekeepingRegistry,
|
||||
} from "../registry";
|
||||
|
||||
export const INBOX_POLICY = Object.freeze({
|
||||
timeoutMs: 2_000,
|
||||
combinedLimit: 200,
|
||||
dedupe: "sourceId:itemId",
|
||||
} as const);
|
||||
|
||||
export interface HousekeepingInboxResponse {
|
||||
readonly items: readonly HousekeepingWorkItem[];
|
||||
readonly errors: readonly {
|
||||
readonly sourceId: string;
|
||||
readonly code: HousekeepingErrorCode;
|
||||
}[];
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingInboxServiceDependencies {
|
||||
readonly registry: HousekeepingRegistry;
|
||||
readonly createCorrelationId?: () => string;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
const priorityRank = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
} as const;
|
||||
|
||||
const severities = new Set(["info", "warning", "critical"]);
|
||||
const priorities = new Set(Object.keys(priorityRank));
|
||||
const freshnessValues = new Set(["fresh", "stale"]);
|
||||
|
||||
function hasUsableCapability(
|
||||
value: unknown,
|
||||
): value is HousekeepingWorkItem["capability"] {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const capability = value as { mode?: unknown; slugs?: unknown };
|
||||
return (
|
||||
(capability.mode === "any" || capability.mode === "all") &&
|
||||
Array.isArray(capability.slugs) &&
|
||||
capability.slugs.length > 0 &&
|
||||
capability.slugs.every((slug) => typeof slug === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isValidWorkItem(
|
||||
item: HousekeepingWorkItem,
|
||||
source: HousekeepingInboxSource,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): boolean {
|
||||
return (
|
||||
item !== null &&
|
||||
typeof item === "object" &&
|
||||
item.sourceId === source.id &&
|
||||
item.domain === source.owner &&
|
||||
typeof item.itemId === "string" &&
|
||||
item.itemId.trim().length > 0 &&
|
||||
typeof item.deduplicationKey === "string" &&
|
||||
item.deduplicationKey.trim().length > 0 &&
|
||||
severities.has(item.severity) &&
|
||||
priorities.has(item.priority) &&
|
||||
Number.isFinite(item.ageMs) &&
|
||||
item.ageMs >= 0 &&
|
||||
typeof item.state === "string" &&
|
||||
item.state.trim().length > 0 &&
|
||||
typeof item.occurredAt === "string" &&
|
||||
Number.isFinite(Date.parse(item.occurredAt)) &&
|
||||
typeof item.titleKey === "string" &&
|
||||
item.titleKey.trim().length > 0 &&
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
freshnessValues.has(item.freshness) &&
|
||||
Array.isArray(item.actions) &&
|
||||
hasUsableCapability(item.capability) &&
|
||||
satisfiesCapability(context, item.capability)
|
||||
);
|
||||
}
|
||||
|
||||
function isPermittedAction(
|
||||
action: HousekeepingWorkItem["actions"][number],
|
||||
context: HousekeepingCapabilityContext,
|
||||
): boolean {
|
||||
return (
|
||||
action !== null &&
|
||||
typeof action === "object" &&
|
||||
typeof action.id === "string" &&
|
||||
action.id.trim().length > 0 &&
|
||||
typeof action.labelKey === "string" &&
|
||||
action.labelKey.trim().length > 0 &&
|
||||
typeof action.commandId === "string" &&
|
||||
action.commandId.trim().length > 0 &&
|
||||
hasUsableCapability(action.capability) &&
|
||||
satisfiesCapability(context, action.capability)
|
||||
);
|
||||
}
|
||||
|
||||
function sourceProvider(
|
||||
source: HousekeepingInboxSource,
|
||||
): HousekeepingProvider<HousekeepingWorkItem> {
|
||||
return {
|
||||
id: source.id,
|
||||
capability: source.capability,
|
||||
async run(context, signal) {
|
||||
const result = await source.getItems(context, signal);
|
||||
if (!result.ok) return result;
|
||||
if (result.data.availability !== "available") {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
result.correlationId,
|
||||
);
|
||||
}
|
||||
return ok(result.data.items, result.correlationId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createHousekeepingInboxService({
|
||||
registry,
|
||||
createCorrelationId: correlationIdFactory = createCorrelationId,
|
||||
timeoutMs = INBOX_POLICY.timeoutMs,
|
||||
}: HousekeepingInboxServiceDependencies) {
|
||||
return async function loadInbox(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingInboxResponse> {
|
||||
const sources = registry.domains.flatMap((domain) => {
|
||||
if (!satisfiesCapability(context, domain.capability)) return [];
|
||||
return domain.inboxSources.filter((source) =>
|
||||
satisfiesCapability(context, source.capability),
|
||||
);
|
||||
});
|
||||
const outcomes = await Promise.all(
|
||||
sources.map((source) =>
|
||||
runProvider(sourceProvider(source), context, timeoutMs),
|
||||
),
|
||||
);
|
||||
const sourceById = new Map(sources.map((source) => [source.id, source]));
|
||||
const seen = new Set<string>();
|
||||
const items: HousekeepingWorkItem[] = [];
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
const source = sourceById.get(outcome.providerId);
|
||||
if (!source) continue;
|
||||
for (const item of outcome.items) {
|
||||
if (!isValidWorkItem(item, source, context)) continue;
|
||||
const key = JSON.stringify([item.sourceId, item.itemId]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push({
|
||||
...item,
|
||||
actions: item.actions.filter((action) =>
|
||||
isPermittedAction(action, context),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((left, right) => {
|
||||
const priority =
|
||||
priorityRank[left.priority] - priorityRank[right.priority];
|
||||
if (priority !== 0) return priority;
|
||||
return right.ageMs - left.ageMs;
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.slice(0, INBOX_POLICY.combinedLimit),
|
||||
errors: outcomes.flatMap((outcome) =>
|
||||
outcome.error
|
||||
? [{ sourceId: outcome.providerId, code: outcome.error }]
|
||||
: [],
|
||||
),
|
||||
correlationId: correlationIdFactory(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadHousekeepingInbox(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingInboxResponse> {
|
||||
const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests");
|
||||
const service = createHousekeepingInboxService({
|
||||
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||
});
|
||||
return service(context);
|
||||
}
|
||||
Reference in new issue
Block a user