diff --git a/package.json b/package.json index 69f2ecc..a0d4e60 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "cfw-gateway", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.33.0", "type": "module", "scripts": { "dev": "wrangler dev", diff --git a/src/index.ts b/src/index.ts index 9410bfe..8ef86af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Scalar } from "@scalar/hono-api-reference"; import { Hono } from "hono"; import type { Env } from "./env"; import { composeOpenApiDocument, fetchServiceOpenApiDocument } from "./openapi"; +import { createPluginRegistryDocument } from "./plugin-registry"; import { resolveRoute } from "./route-policy"; const app = new Hono<{ Bindings: Env }>(); @@ -74,6 +75,12 @@ app.all("*", async (c) => { } } + if (target.kind === "gateway-plugin-manifest") { + return c.json(createPluginRegistryDocument(), 200, { + "cache-control": "public, max-age=60, stale-while-revalidate=300", + }); + } + if (target.kind === "gateway-docs") { return gatewayReference(c, async () => undefined); } diff --git a/src/plugin-registry.ts b/src/plugin-registry.ts new file mode 100644 index 0000000..d34d0a3 --- /dev/null +++ b/src/plugin-registry.ts @@ -0,0 +1,174 @@ +export type PluginStatus = "enabled" | "planned" | "disabled"; + +export type PluginCapability = + | "routes" + | "menu" + | "settings" + | "ops" + | "notifications" + | "user-center"; + +export type PluginSlotContribution = { + plugin: string; + slot: + | "sidebar.primary" + | "sidebar.secondary" + | "topbar.actions" + | "notifications.panel" + | "user-menu.items" + | "dashboard.cards" + | "settings.sections"; + label: string; + path: string; + order: number; + permission?: string; +}; + +export type PluginNavigationContribution = { + label: string; + path: string; + order: number; + permission?: string; +}; + +export type GatewayPluginManifest = { + name: string; + version: string; + entry: string; + module: string; + owner: string; + status: PluginStatus; + routes: string[]; + home: { + path: string; + title: string; + description?: string; + }; + navigation: PluginNavigationContribution[]; + slots: PluginSlotContribution[]; + permissions: string[]; + capabilities: PluginCapability[]; +}; + +export type GatewayPluginRegistryDocument = { + schemaVersion: 1; + kind: "cfw-plugin-registry"; + generatedAt: string; + plugins: GatewayPluginManifest[]; +}; + +const plugins: GatewayPluginManifest[] = [ + { + name: "account-center", + version: "0.1.0", + entry: "account_center@/apps/account-center/mf-manifest.json", + module: "./App", + owner: "cfw-auth", + status: "enabled", + routes: ["/account-center/*"], + home: { + path: "/account-center", + title: "Account Center", + description: "User, organization and API key management.", + }, + navigation: [ + { + label: "Account Center", + path: "/account-center", + order: 100, + permission: "session:read", + }, + ], + slots: [ + { + plugin: "account-center", + slot: "user-menu.items", + label: "API keys", + path: "/account/api-keys", + order: 100, + permission: "api-key:manage", + }, + ], + permissions: ["session:read", "api-key:manage"], + capabilities: ["routes", "menu", "user-center"], + }, + { + name: "ops-console", + version: "0.1.0", + entry: "ops_console@/apps/ops-console/mf-manifest.json", + module: "./App", + owner: "cfw-ops", + status: "planned", + routes: ["/ops-console/*"], + home: { + path: "/ops-console", + title: "Ops Console", + description: "Observations, incidents and notification delivery.", + }, + navigation: [ + { + label: "Ops Console", + path: "/ops-console", + order: 200, + permission: "ops:read", + }, + ], + slots: [ + { + plugin: "ops-console", + slot: "notifications.panel", + label: "Incidents", + path: "/ops/incidents", + order: 100, + permission: "incident:read", + }, + ], + permissions: ["ops:read", "incident:write"], + capabilities: ["routes", "menu", "ops", "notifications"], + }, + { + name: "billing", + version: "0.1.0", + entry: "billing@/apps/billing/mf-manifest.json", + module: "./App", + owner: "business-worker", + status: "planned", + routes: ["/billing/*"], + home: { + path: "/billing", + title: "Billing", + description: "Business-owned billing entry page.", + }, + navigation: [ + { + label: "Billing", + path: "/billing", + order: 300, + permission: "billing:read", + }, + ], + slots: [ + { + plugin: "billing", + slot: "sidebar.primary", + label: "Billing reports", + path: "/billing/reports", + order: 300, + permission: "billing:read", + }, + ], + permissions: ["billing:read"], + capabilities: ["routes", "menu", "settings"], + }, +]; + +export function createPluginRegistryDocument( + generatedAt: string = new Date().toISOString(), +): GatewayPluginRegistryDocument { + return { + schemaVersion: 1, + kind: "cfw-plugin-registry", + generatedAt, + plugins, + }; +} diff --git a/src/route-policy.ts b/src/route-policy.ts index 4cb6711..99531d0 100644 --- a/src/route-policy.ts +++ b/src/route-policy.ts @@ -7,6 +7,7 @@ export type RouteTarget = | { kind: "gateway-docs" } | { kind: "gateway-health" } | { kind: "gateway-openapi" } + | { kind: "gateway-plugin-manifest" } | { kind: "not-found" }; export function resolveRoute(request: Request, env: Env): RouteTarget { @@ -24,6 +25,10 @@ export function resolveRoute(request: Request, env: Env): RouteTarget { return { kind: "gateway-openapi" }; } + if (url.pathname === "/mf-manifest.json") { + return { kind: "gateway-plugin-manifest" }; + } + if (url.pathname.startsWith("/api/auth/")) { return { kind: "auth" }; } diff --git a/tests/gateway-worker.test.ts b/tests/gateway-worker.test.ts index 9bfabba..6a84949 100644 --- a/tests/gateway-worker.test.ts +++ b/tests/gateway-worker.test.ts @@ -112,6 +112,48 @@ describe("cfw-gateway worker", () => { expect(schema.paths["/api/scheduler/jobs"]).toBeTruthy(); }); + it("serves the gateway-owned plugin manifest without consulting services", async () => { + const auth = createJsonService(401, { authenticated: false }); + const ops = createJsonService(404, { service: "ops" }); + const scheduler = createJsonService(404, { service: "scheduler" }); + const env = createEnv({ + AUTH: auth, + OPS: ops, + SCHEDULER: scheduler, + }); + + const response = await worker.fetch(new Request("http://gateway.local/mf-manifest.json"), env); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("cache-control")).toBe("public, max-age=60, stale-while-revalidate=300"); + const manifest = await response.json() as { + schemaVersion: number; + kind: string; + plugins: Array<{ + name: string; + entry: string; + routes: string[]; + status: string; + }>; + }; + expect(manifest.schemaVersion).toBe(1); + expect(manifest.kind).toBe("cfw-plugin-registry"); + expect(manifest.plugins).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "account-center", + entry: "account_center@/apps/account-center/mf-manifest.json", + routes: ["/account-center/*"], + status: "enabled", + }), + ]), + ); + expect(auth.fetch).not.toHaveBeenCalled(); + expect(ops.fetch).not.toHaveBeenCalled(); + expect(scheduler.fetch).not.toHaveBeenCalled(); + }); + it("returns a diagnostic error when aggregated OpenAPI fetch fails", async () => { const env = createEnv({ AUTH: { diff --git a/tests/route-policy.test.ts b/tests/route-policy.test.ts index 69398b8..d1e3091 100644 --- a/tests/route-policy.test.ts +++ b/tests/route-policy.test.ts @@ -16,6 +16,9 @@ describe("route policy", () => { expect(resolveRoute(new Request("http://gateway.local/openapi.json"), env)).toEqual({ kind: "gateway-openapi", }); + expect(resolveRoute(new Request("http://gateway.local/mf-manifest.json"), env)).toEqual({ + kind: "gateway-plugin-manifest", + }); }); it("routes each service prefix to its service binding", () => {