248 lines
7.0 KiB
TypeScript
248 lines
7.0 KiB
TypeScript
import { PERMS } from "@/lib/permission-slugs";
|
|
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
|
|
import { satisfiesCapability } from "./capability-context";
|
|
import type {
|
|
CapabilityRequirement,
|
|
HousekeepingCapabilityContext,
|
|
HousekeepingDomainManifest,
|
|
HousekeepingErrorCode,
|
|
HousekeepingWidgetDefinition,
|
|
} from "./contracts";
|
|
import { ok } from "./contracts";
|
|
import { reconcilePreferences } from "./preferences/reconcile";
|
|
import type { HousekeepingPreferences } from "./preferences/schema";
|
|
import {
|
|
type HousekeepingProvider,
|
|
runProvider,
|
|
} from "./providers/run-provider";
|
|
|
|
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
|
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
|
interface OwnedRegistryEntry {
|
|
id: string;
|
|
owner: HousekeepingDomainManifest["id"];
|
|
capability: CapabilityRequirement;
|
|
}
|
|
|
|
export interface HousekeepingRegistry {
|
|
domains: readonly HousekeepingDomainManifest[];
|
|
}
|
|
|
|
export const WIDGET_POLICY = Object.freeze({ timeoutMs: 2_000 } as const);
|
|
|
|
export interface HousekeepingWidgetLoadResult {
|
|
readonly widgets: readonly { readonly id: string; readonly data: unknown }[];
|
|
readonly errors: readonly {
|
|
readonly widgetId: string;
|
|
readonly code: HousekeepingErrorCode;
|
|
}[];
|
|
}
|
|
|
|
function widgetProvider(
|
|
widget: HousekeepingWidgetDefinition,
|
|
): HousekeepingProvider<unknown> {
|
|
return {
|
|
id: widget.id,
|
|
capability: widget.capability,
|
|
async run(context, signal) {
|
|
const result = await widget.load(context, signal);
|
|
return result.ok ? ok([result.data], result.correlationId) : result;
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function loadHousekeepingWidgets(
|
|
registry: HousekeepingRegistry,
|
|
preferences: HousekeepingPreferences,
|
|
context: HousekeepingCapabilityContext,
|
|
timeoutMs: number = WIDGET_POLICY.timeoutMs,
|
|
): Promise<HousekeepingWidgetLoadResult> {
|
|
const reconciled = reconcilePreferences(preferences, registry, context);
|
|
const enabledOptionalIds = new Set(reconciled.enabledOptionalWidgetIds);
|
|
const selected = registry.domains
|
|
.filter((domain) => satisfiesCapability(context, domain.capability))
|
|
.flatMap((domain) => domain.widgets)
|
|
.filter(
|
|
(widget) =>
|
|
satisfiesCapability(context, widget.capability) &&
|
|
(widget.kind === "mandatory" || enabledOptionalIds.has(widget.id)),
|
|
);
|
|
const selectedById = new Map(selected.map((widget) => [widget.id, widget]));
|
|
const orderedIds = reconciled.widgetOrder.filter((id) =>
|
|
selectedById.has(id),
|
|
);
|
|
for (const widget of selected) {
|
|
if (!orderedIds.includes(widget.id)) orderedIds.push(widget.id);
|
|
}
|
|
const ordered = orderedIds.flatMap((id) => {
|
|
const widget = selectedById.get(id);
|
|
return widget ? [widget] : [];
|
|
});
|
|
const outcomes = await Promise.all(
|
|
ordered.map((widget) =>
|
|
runProvider(widgetProvider(widget), context, timeoutMs),
|
|
),
|
|
);
|
|
|
|
return {
|
|
widgets: outcomes.flatMap((outcome) =>
|
|
outcome.error === undefined
|
|
? [{ id: outcome.providerId, data: outcome.items[0] }]
|
|
: [],
|
|
),
|
|
errors: outcomes.flatMap((outcome) =>
|
|
outcome.error === undefined
|
|
? []
|
|
: [{ widgetId: outcome.providerId, code: outcome.error }],
|
|
),
|
|
};
|
|
}
|
|
|
|
export function createHousekeepingRegistry(
|
|
manifests: readonly HousekeepingDomainManifest[],
|
|
): HousekeepingRegistry {
|
|
const domainIds = new Set<string>();
|
|
const routeIds = new Set<string>();
|
|
const routeHrefs = new Set<string>();
|
|
const routeShapes = new Set<string>();
|
|
const searchProviderIds = new Set<string>();
|
|
const inboxSourceIds = new Set<string>();
|
|
const widgetIds = new Set<string>();
|
|
|
|
for (const manifest of manifests) {
|
|
if (!approvedDomainIds.has(manifest.id)) {
|
|
throw new Error(`unknown domain id: ${manifest.id}`);
|
|
}
|
|
if (domainIds.has(manifest.id)) {
|
|
throw new Error(`duplicate domain id: ${manifest.id}`);
|
|
}
|
|
domainIds.add(manifest.id);
|
|
|
|
if (
|
|
manifest.canonicalHref !==
|
|
(manifest.id === "operations" ? "/ase" : `/ase/${manifest.id}`)
|
|
) {
|
|
throw new Error(`invalid canonical href: ${manifest.canonicalHref}`);
|
|
}
|
|
validateNonEmpty(manifest.labelKey, "label key");
|
|
validateNonEmpty(manifest.descriptionKey, "description key");
|
|
validateCapability(manifest.capability);
|
|
validateOwnedRegistryEntries(
|
|
manifest.searchProviders,
|
|
manifest.id,
|
|
"search provider",
|
|
searchProviderIds,
|
|
);
|
|
validateOwnedRegistryEntries(
|
|
manifest.inboxSources,
|
|
manifest.id,
|
|
"inbox source",
|
|
inboxSourceIds,
|
|
);
|
|
validateOwnedRegistryEntries(
|
|
manifest.widgets,
|
|
manifest.id,
|
|
"widget",
|
|
widgetIds,
|
|
);
|
|
for (const widget of manifest.widgets) {
|
|
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
|
throw new Error(`invalid widget kind: ${widget.id}`);
|
|
}
|
|
}
|
|
|
|
for (const route of manifest.routes) {
|
|
validateNonEmpty(route.id, "route id");
|
|
if (routeIds.has(route.id)) {
|
|
throw new Error(`duplicate route id: ${route.id}`);
|
|
}
|
|
routeIds.add(route.id);
|
|
|
|
validateNonEmpty(route.href, "route href");
|
|
if (route.href !== "/ase" && !route.href.startsWith("/ase/")) {
|
|
throw new Error(`invalid canonical route href: ${route.href}`);
|
|
}
|
|
if (routeHrefs.has(route.href)) {
|
|
throw new Error(`duplicate route href: ${route.href}`);
|
|
}
|
|
routeHrefs.add(route.href);
|
|
|
|
const routeShape = dynamicRouteShape(route.href);
|
|
if (routeShape && routeShapes.has(routeShape)) {
|
|
throw new Error(`duplicate dynamic route shape: ${routeShape}`);
|
|
}
|
|
if (routeShape) routeShapes.add(routeShape);
|
|
|
|
validateNonEmpty(route.labelKey, "route label key");
|
|
validateCapability(route.capability);
|
|
}
|
|
}
|
|
|
|
return { domains: Object.freeze([...manifests]) };
|
|
}
|
|
|
|
function dynamicRouteShape(href: string): string | null {
|
|
let hasDynamicSegment = false;
|
|
const shape = href
|
|
.split("/")
|
|
.map((segment) => {
|
|
if (!segment.startsWith(":")) return segment;
|
|
hasDynamicSegment = true;
|
|
return ":";
|
|
})
|
|
.join("/");
|
|
|
|
return hasDynamicSegment ? shape : null;
|
|
}
|
|
|
|
function validateOwnedRegistryEntries(
|
|
entries: readonly OwnedRegistryEntry[],
|
|
owner: HousekeepingDomainManifest["id"],
|
|
kind: "search provider" | "inbox source" | "widget",
|
|
ids: Set<string>,
|
|
): void {
|
|
for (const entry of entries) {
|
|
validateNonEmpty(entry.id, `${kind} id`);
|
|
if (ids.has(entry.id)) {
|
|
throw new Error(`duplicate ${kind} id: ${entry.id}`);
|
|
}
|
|
ids.add(entry.id);
|
|
|
|
if (entry.owner !== owner) {
|
|
throw new Error(`${kind} owner mismatch: ${entry.id}`);
|
|
}
|
|
|
|
validateCapability(entry.capability);
|
|
}
|
|
}
|
|
|
|
function validateNonEmpty(value: string, name: string): void {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
throw new Error(`empty ${name}`);
|
|
}
|
|
}
|
|
|
|
function validateCapability(requirement: CapabilityRequirement): void {
|
|
if (
|
|
typeof requirement !== "object" ||
|
|
requirement === null ||
|
|
(requirement.mode !== "any" && requirement.mode !== "all") ||
|
|
!Array.isArray(requirement.slugs)
|
|
) {
|
|
throw new Error("invalid capability requirement");
|
|
}
|
|
|
|
if (requirement.slugs.length === 0) {
|
|
throw new Error("empty capability requirement");
|
|
}
|
|
|
|
for (const slug of requirement.slugs) {
|
|
if (typeof slug !== "string") {
|
|
throw new Error("invalid capability requirement");
|
|
}
|
|
if (!knownCapabilitySlugs.has(slug)) {
|
|
throw new Error(`unknown capability slug: ${slug}`);
|
|
}
|
|
}
|
|
}
|