diff --git a/.changeset/openapi-documentation.md b/.changeset/openapi-documentation.md new file mode 100644 index 0000000000..2b105a4038 --- /dev/null +++ b/.changeset/openapi-documentation.md @@ -0,0 +1,39 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-api-docs": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch +--- + +Add OpenAPI documentation, delivered as a plugin. The new +`@nextlyhq/plugin-api-docs` generates a complete OpenAPI 3.1 spec at request time +from three derived sources — a filesystem scan of the app's route files (which +discovers mounts and their verbs, including the media double-mount), the admin +REST operations exposed by a new read-only `listAdminRestOperations()` seam, and +every registered plugin's routes via `listPluginRoutes()` — and serves it plus an +interactive Scalar reference, admin-gated by default. The error component is +generated from the live error-code enum, plugin routes can carry an optional +`openapi?` annotation, and the plugin exposes typed excludes (paths, services, +error codes) and explicit mount overrides. Core nextly gains only the two small +introspection seams on the plugin-sdk surface. diff --git a/apps/playground/nextly.config.ts b/apps/playground/nextly.config.ts index a4ae50d7b2..39bff5cba4 100644 --- a/apps/playground/nextly.config.ts +++ b/apps/playground/nextly.config.ts @@ -23,6 +23,7 @@ * runtime ignores this field. See packages/nextly/src/auth/handlers/session.ts. */ +import { apiDocsPlugin } from "@nextlyhq/plugin-api-docs"; import { formBuilderPlugin } from "@nextlyhq/plugin-form-builder"; import { pageBuilder } from "@nextlyhq/plugin-page-builder"; import { defineConfig } from "nextly/config"; @@ -82,17 +83,20 @@ export default defineConfig({ singles: [Homepage, LandingPage, SiteSettings], fieldGroups: [Seo], // Dev-harness plugins: page builder and form builder are what a contributor - // works against. + // works against. The api-docs plugin is the local test surface for OpenAPI + // documentation — it serves the Scalar reference at /admin/api/docs and the + // spec at /admin/api/docs/spec.json, and adds an "API Docs" sidebar entry. // // The styling fixture is registered only for the e2e run. It exists so - // plugin-admin-styling.spec.ts can prove a plugin's admin UI is styled in the - // real admin, and plugin-page-routing.spec.ts can resolve a deep link to a + // plugin-admin-styling.spec.ts can prove a plugin's admin UI is styled in + // the real admin, and plugin-page-routing.spec.ts can resolve a deep link to a // plugin page. In a normal `pnpm dev:app` it is neither of those things: it // is a test double listed among real plugins, and it injects a showcase // section into the Posts collection list, both of which read as product. plugins: [ pageBuilder(), formBuilderPlugin, + apiDocsPlugin({ visibility: "public" }), ...(process.env.NEXTLY_E2E_STYLE_FIXTURE === "1" ? [styleFixturePlugin] : []), diff --git a/apps/playground/package.json b/apps/playground/package.json index 9037462b96..9dc48b11e5 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -28,6 +28,7 @@ "@nextlyhq/admin": "workspace:^", "@nextlyhq/blocks-react": "workspace:^", "@nextlyhq/builder": "workspace:*", + "@nextlyhq/plugin-api-docs": "workspace:^", "@nextlyhq/plugin-form-builder": "workspace:^", "@nextlyhq/plugin-page-builder": "workspace:^", "@nextlyhq/plugin-sdk": "workspace:^", diff --git a/packages/admin/src/components/features/dashboard/PluginMenuItems.tsx b/packages/admin/src/components/features/dashboard/PluginMenuItems.tsx index c5b409fda0..74389866d0 100644 --- a/packages/admin/src/components/features/dashboard/PluginMenuItems.tsx +++ b/packages/admin/src/components/features/dashboard/PluginMenuItems.tsx @@ -16,7 +16,6 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, } from "@admin/components/layout/sidebar"; -import { Link } from "@admin/components/ui/link"; import { useBranding } from "@admin/context/providers/BrandingProvider"; import { useCurrentUserPermissions } from "@admin/hooks/useCurrentUserPermissions"; import { resolveVisibleMenuItems } from "@admin/lib/plugins/menu"; @@ -74,12 +73,17 @@ function PluginMenuLeaf({ return ( - + {/* A plugin menu item is an arbitrary URL by contract — it may point at + a plugin HTTP route (e.g. the api-docs reference) rather than an + admin page. Client-side routing such a URL resolves against the page + tree and lands on the admin 404, so plugin items navigate with a + full page load. */} + {item.label} - + ); @@ -121,10 +125,12 @@ function PluginMenuBranch({ return ( - + {/* Same full-load contract as the leaf: a child may target a + plugin HTTP route, which is not an admin page. */} + {child.label} - + ); diff --git a/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx b/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx index f5a19c9e8e..25b041c59d 100644 --- a/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx +++ b/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx @@ -2,6 +2,7 @@ import { DynamicCollectionNav } from "@admin/components/features/dashboard/Dynam import { DynamicPluginNav } from "@admin/components/features/dashboard/DynamicPluginNav"; import { DynamicPluginSectionItems } from "@admin/components/features/dashboard/DynamicPluginSectionItems"; import { DynamicSingleNav } from "@admin/components/features/dashboard/DynamicSingleNav"; +import { PluginMenuItems } from "@admin/components/features/dashboard/PluginMenuItems"; import * as Icons from "@admin/components/icons"; import { Layers, Puzzle, FileText, Database } from "@admin/components/icons"; import { Link } from "@admin/components/ui/link"; @@ -107,8 +108,10 @@ export function SubSidebarContent({ onChange={onPluginSearchChange} />
- {/* Names what the panel contains: the installed-plugins overview and - navigation into each plugin's collections. Nothing here installs + {/* Names what the panel contains: the installed-plugins overview, + navigation into each plugin's collections, and any declarative + menu items plugins contribute via `contributes.admin.menu` — + e.g. the api-docs plugin's reference link. Nothing here installs a plugin, which happens through the Nextly config. */}

Plugins @@ -138,6 +141,7 @@ export function SubSidebarContent({ )} +

diff --git a/packages/nextly/src/dispatcher/handlers/user-dispatcher.ts b/packages/nextly/src/dispatcher/handlers/user-dispatcher.ts index 6d0541e20c..73d755e274 100644 --- a/packages/nextly/src/dispatcher/handlers/user-dispatcher.ts +++ b/packages/nextly/src/dispatcher/handlers/user-dispatcher.ts @@ -38,7 +38,10 @@ import type { MethodHandler, Params } from "../types"; type UsersService = ServiceContainer["users"]; -const USER_METHODS: Record> = { +// Exported (additive; dispatch behaviour unchanged) so the OpenAPI route- +// descriptor registry's agreement test can assert its `users` operations are a +// subset of this live map — the single source of truth for which methods exist. +export const USER_METHODS: Record> = { listUsers: { execute: async (svc, p) => { const result = await svc.listUsers({ diff --git a/packages/nextly/src/index.ts b/packages/nextly/src/index.ts index e5994bbd45..6242d43f6b 100644 --- a/packages/nextly/src/index.ts +++ b/packages/nextly/src/index.ts @@ -436,6 +436,7 @@ export { type PluginFilterRegistry, type PluginActionRegistry, type PluginRoute, + type PluginRouteOpenApi, type PluginRouteContext, type PluginRouteHandler, type Middleware, @@ -819,3 +820,47 @@ export { type MediaLike, type GetMediaVariantOptions, } from "./lib/media-variant"; + +// ============================================================ +// ADMIN REST INTROSPECTION +// ============================================================ + +// General "what REST operations does the admin catch-all expose" seam. Not +// OpenAPI-specific — the api-docs plugin consumes it, and any introspection +// tooling (client generators, permission auditors) can too. +export { + listAdminRestOperations, + restOperationsForService, + dedupeRestOperations, + type AdminRestOperation, + type RestHttpMethod, + type RestAuthMode, +} from "./route-handler/admin-rest-descriptors"; + +// Content-surface introspection: every registered collection/single with its +// fields, across code-first, plugin-contributed, and admin-Builder origins. +export { + listContentSurfaces, + type ContentSurfaceInfo, + type ContentSurfaces, +} from "./route-handler/content-surfaces"; + +// Mounted-surface introspection: operation templates for the standalone +// first-party handlers (media factory, health check) a host app mounts. Lives +// beside the catch-all tables in admin-rest-descriptors — one routes file. +export { + listMediaSurfaceOperations, + listHealthSurfaceOperations, + type MountedSurfaceOperation, +} from "./route-handler/admin-rest-descriptors"; + +// Request-auth introspection: is this caller carrying a valid session/API key? +// Lets a plugin serving a public route distinguish anonymous from logged-in. +export { isAuthenticatedApiRequest } from "./route-handler/request-auth"; + +// Read-only view of plugin-contributed routes (the plugin-route mirror of the +// admin REST introspection above). +export { + listPluginRoutes, + type PluginRouteInfo, +} from "./plugins/routes/route-registry"; diff --git a/packages/nextly/src/plugins/index.ts b/packages/nextly/src/plugins/index.ts index 74c194e6e6..c21076d4bd 100644 --- a/packages/nextly/src/plugins/index.ts +++ b/packages/nextly/src/plugins/index.ts @@ -66,8 +66,15 @@ export type { // Plugin HTTP routes — `contributes.routes` surface. export type { PluginRoute, + PluginRouteOpenApi, PluginRouteContext, PluginRouteHandler, Middleware, RouteMethod, } from "./routes/route-types"; + +// Read-only plugin-route introspection (safe view, no contexts/handlers). +export { + listPluginRoutes, + type PluginRouteInfo, +} from "./routes/route-registry"; diff --git a/packages/nextly/src/plugins/routes/collect-routes.test.ts b/packages/nextly/src/plugins/routes/collect-routes.test.ts index 8c80e59935..848d24c303 100644 --- a/packages/nextly/src/plugins/routes/collect-routes.test.ts +++ b/packages/nextly/src/plugins/routes/collect-routes.test.ts @@ -19,7 +19,11 @@ function thrownCode(fn: () => unknown): string { function plugin( name: string, - routes: Array<{ method: "GET" | "POST"; path: string }>, + routes: Array<{ + method: "GET" | "POST"; + path: string; + mount?: "admin-api"; + }>, enabled?: boolean ): PluginDefinition { return { @@ -62,6 +66,25 @@ describe("collectPluginRoutes", () => { expect(collected).toHaveLength(2); }); + it("keeps an admin-api route directly under the admin API root", () => { + const [route] = collectPluginRoutes([ + plugin("@a/docs", [{ method: "GET", path: "/docs", mount: "admin-api" }]), + ]); + expect(route?.fullPath).toBe("/docs"); + }); + + it("rejects an admin-api route that would shadow the REST surface", () => { + expect( + thrownCode(() => + collectPluginRoutes([ + plugin("@a/docs", [ + { method: "GET", path: "/users", mount: "admin-api" }, + ]), + ]) + ) + ).toBe("NEXTLY_ROUTE_COLLISION"); + }); + it("rejects a path without a leading slash", () => { expect( thrownCode(() => @@ -78,3 +101,26 @@ describe("collectPluginRoutes", () => { ).toEqual([]); }); }); + +describe("admin-api dynamic-segment guard", () => { + it("rejects an admin-api route whose first segment is dynamic (:resource wildcards every REST path)", () => { + expect( + thrownCode(() => + collectPluginRoutes([ + plugin("@a/grab", [ + { method: "GET", path: "/:resource", mount: "admin-api" }, + ]), + ]) + ) + ).toBe("NEXTLY_ROUTE_COLLISION"); + }); + + it("still allows a dynamic segment AFTER a static first segment", () => { + const routes = collectPluginRoutes([ + plugin("@a/docs", [ + { method: "GET", path: "/docs/:pageId", mount: "admin-api" }, + ]), + ]); + expect(routes[0]?.fullPath).toBe("/docs/:pageId"); + }); +}); diff --git a/packages/nextly/src/plugins/routes/collect-routes.ts b/packages/nextly/src/plugins/routes/collect-routes.ts index 547eb645ff..48d1ce8a10 100644 --- a/packages/nextly/src/plugins/routes/collect-routes.ts +++ b/packages/nextly/src/plugins/routes/collect-routes.ts @@ -1,3 +1,4 @@ +import { listAdminRestOperations } from "../../route-handler/admin-rest-descriptors"; import type { PluginDefinition } from "../plugin-context"; import { routeCollisionError, routeInvalidPathError } from "./route-error"; @@ -10,18 +11,36 @@ export interface CollectedRoute { method: PluginRoute["method"]; /** The plugin-declared path (within its namespace). */ path: string; - /** Namespaced path: `/plugins/`. */ + /** Match path under the admin API root (namespaced, or the path for admin-api mounts). */ fullPath: string; route: PluginRoute; } +/** + * First path segments an `admin-api` mounted route may not use: every system + * REST resource (derived live from the admin REST seam, so it stays current as + * the surface grows) plus the plugin namespace itself, the media sub-mount, and + * the dev-only branches. A plugin route matching BEFORE the REST router would + * shadow the built-in handler, so this is refused at boot. + */ +function reservedAdminApiSegments(): Set { + const segments = new Set(["plugins", "media", "_dev", "dev-reload"]); + for (const op of listAdminRestOperations()) { + const first = op.path.split("/").filter(Boolean)[0]; + if (first) segments.add(first); + } + return segments; +} + /** * Pure fold of every ENABLED plugin's `contributes.routes` into namespaced, * collision-checked routes. Disabled plugins (`enabled: false`) skip * behavior — including routes — while their schema is still applied. * - * Throws {@link routeInvalidPathError} for a path without a leading slash and - * {@link routeCollisionError} when two routes share a `(method, full path)`. + * Throws {@link routeInvalidPathError} for a path without a leading slash, + * {@link routeCollisionError} when two routes share a `(method, full path)`, and + * a collision error when an `admin-api` mounted route's first segment names a + * system REST resource. */ export function collectPluginRoutes( plugins: PluginDefinition[] @@ -29,6 +48,7 @@ export function collectPluginRoutes( const collected: CollectedRoute[] = []; // Tracks the first owner of each (method, fullPath) for collision reporting. const seen = new Map(); + const reserved = reservedAdminApiSegments(); for (const plugin of plugins) { if (plugin.enabled === false) continue; @@ -39,7 +59,21 @@ export function collectPluginRoutes( if (!route.path.startsWith("/")) { throw routeInvalidPathError(plugin.name, route.path); } - const fullPath = pluginRouteFullPath(plugin.name, route.path); + const fullPath = pluginRouteFullPath(plugin.name, route); + // An admin-api route sits ahead of the REST router in dispatch order, so + // neither a system-resource first segment NOR a dynamic one may lead its + // path: `:resource` matches any segment in the registry, which would + // wildcard-shadow the built-in surface the same as a literal name. + const firstSegment = route.path.split("/").filter(Boolean)[0] ?? ""; + if ( + route.mount === "admin-api" && + (firstSegment.startsWith(":") || reserved.has(firstSegment)) + ) { + throw routeCollisionError(route.method, fullPath, [ + plugin.name, + "admin REST surface", + ]); + } const key = `${route.method} ${fullPath}`; const existingOwner = seen.get(key); if (existingOwner !== undefined) { diff --git a/packages/nextly/src/plugins/routes/route-path.ts b/packages/nextly/src/plugins/routes/route-path.ts index bb3c9ec2c8..09c8c91462 100644 --- a/packages/nextly/src/plugins/routes/route-path.ts +++ b/packages/nextly/src/plugins/routes/route-path.ts @@ -1,3 +1,5 @@ +import type { PluginRoute } from "./route-types"; + /** * The namespace a plugin's route is served under. * @@ -12,14 +14,24 @@ * `/admin/plugins/acme-p`; the slug is how the ADMIN names a plugin and has * never been how the dispatcher does. * - * No mount prefix: the host app decides where the Nextly handler is mounted - * (`/api/...` by convention), so that half is the caller's to add. + * No mount prefix beyond the above: the host app decides where the Nextly + * handler is mounted (`/api/...` by convention), so that half is the caller's + * to add. + * + * Two mount modes: the default `plugins` mount is namespaced under + * `/plugins/`; the opt-in `admin-api` mount returns the path + * as-is, serving the route at the admin API root for surfaces that read as + * first-party (the docs plugin's `/docs`). What that mode may not shadow is + * decided in `collect-routes.ts`, which refuses first segments that name or + * wildcard system resources. * * @module plugins/routes/route-path */ export function pluginRouteFullPath( pluginName: string, - routePath: string + route: Pick ): string { - return `/plugins/${pluginName}${routePath}`; + return route.mount === "admin-api" + ? route.path + : `/plugins/${pluginName}${route.path}`; } diff --git a/packages/nextly/src/plugins/routes/route-registry.test.ts b/packages/nextly/src/plugins/routes/route-registry.test.ts index b17d5887b2..7c31700ee5 100644 --- a/packages/nextly/src/plugins/routes/route-registry.test.ts +++ b/packages/nextly/src/plugins/routes/route-registry.test.ts @@ -21,6 +21,17 @@ describe("PluginRouteRegistry", () => { expect(m?.route.path).toBe("/ping"); }); + it("matches an admin-api route without adding the plugin namespace", () => { + const reg = getPluginRouteRegistry(); + reg.register( + "@acme/docs", + { method: "GET", path: "/docs", mount: "admin-api", handler }, + baseCtx + ); + expect(reg.match("GET", "/docs")?.pluginName).toBe("@acme/docs"); + expect(reg.match("GET", "/plugins/@acme/docs/docs")).toBeNull(); + }); + it("captures :params and ignores the wrong method", () => { const reg = getPluginRouteRegistry(); reg.register( diff --git a/packages/nextly/src/plugins/routes/route-registry.ts b/packages/nextly/src/plugins/routes/route-registry.ts index ca969bf1a5..bb758157cc 100644 --- a/packages/nextly/src/plugins/routes/route-registry.ts +++ b/packages/nextly/src/plugins/routes/route-registry.ts @@ -1,3 +1,4 @@ +import type { PermissionSlug } from "../contributions"; import type { PluginContext } from "../plugin-context"; import { pluginRouteFullPath } from "./route-path"; @@ -44,7 +45,9 @@ export class PluginRouteRegistry { route: PluginRoute, baseCtx: PluginContext ): void { - const fullPath = pluginRouteFullPath(pluginName, route.path); + // Same helper collection uses, so the match path can never disagree with + // the collision-checked path. + const fullPath = pluginRouteFullPath(pluginName, route); this.routes.push({ pluginName, method: route.method, @@ -115,3 +118,45 @@ export function getPluginRouteRegistry(): PluginRouteRegistry { export function resetPluginRouteRegistry(): void { globalRegistry.clear(); } + +/** + * A read-only, safe view of one registered plugin route for introspection + * consumers (the api-docs plugin, tooling). Deliberately excludes the boot-built + * `baseCtx` — that carries services and db handles no introspection reader + * should be handed. + */ +export interface PluginRouteInfo { + pluginName: string; + method: RouteMethod; + /** Path within the plugin namespace (leading "/", `:param` segments). */ + path: string; + /** + * Match path under the admin API root: `/plugins/` for the + * default `plugins` mount, or `path` itself for an `admin-api` mount. + */ + fullPath: string; + /** Whether the route is publicly callable (secure by default otherwise). */ + public: boolean; + /** The permission slug required to call the route, when gated. */ + requiredPermission?: PermissionSlug; + /** The route's optional OpenAPI annotation, verbatim. */ + openapi?: PluginRoute["openapi"]; +} + +/** + * List every registered plugin route as a safe, read-only view. General + * introspection (the mirror of `listAdminRestOperations` for plugin-contributed + * routes) — the docs plugin consumes it, and nothing here exposes handler + * functions or contexts. + */ +export function listPluginRoutes(): PluginRouteInfo[] { + return globalRegistry.list().map(entry => ({ + pluginName: entry.pluginName, + method: entry.method, + path: entry.route.path, + fullPath: entry.fullPath, + public: entry.route.public === true, + requiredPermission: entry.route.requiredPermission, + openapi: entry.route.openapi, + })); +} diff --git a/packages/nextly/src/plugins/routes/route-types.ts b/packages/nextly/src/plugins/routes/route-types.ts index c6f9badffb..777f4e724b 100644 --- a/packages/nextly/src/plugins/routes/route-types.ts +++ b/packages/nextly/src/plugins/routes/route-types.ts @@ -61,4 +61,32 @@ export interface PluginRoute { public?: boolean; /** Ordered, typed route-level middleware chain. */ middleware?: Middleware[]; + /** + * Where the route is served. `"plugins"` (default) mounts it under the + * plugin namespace (`/admin/api/plugins/`); `"admin-api"` mounts + * it directly at the admin API root (`/admin/api`) — for surfaces that + * read as first-party API (e.g. the docs plugin's `/docs`). Admin-api routes + * are refused at boot when their first path segment collides with the system + * REST surface, so a plugin cannot shadow built-in routes. + */ + mount?: "plugins" | "admin-api"; + /** + * Optional OpenAPI metadata for this route. Derivation stays zero-action by + * default (path, method, and security come from the route itself); this only + * enriches the generated operation with a summary/description/tags. + */ + openapi?: PluginRouteOpenApi; +} + +/** + * @public Optional OpenAPI metadata a plugin attaches to a route. + * Every field is optional and flows into the generated operation verbatim. + */ +export interface PluginRouteOpenApi { + /** A short, human-readable operation summary. */ + summary?: string; + /** A longer operation description. */ + description?: string; + /** Extra OpenAPI tags for grouping, merged with the plugin's default tag. */ + tags?: string[]; } diff --git a/packages/nextly/src/route-handler/admin-rest-descriptors.test.ts b/packages/nextly/src/route-handler/admin-rest-descriptors.test.ts new file mode 100644 index 0000000000..6849597c4b --- /dev/null +++ b/packages/nextly/src/route-handler/admin-rest-descriptors.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for the admin REST operation introspection seam. + * + * The headline guard is the per-service AGREEMENT test: every listed operation + * must exist in the live `*_METHODS` map, so a renamed dispatcher method fails + * here instead of silently producing a stale list. The list is intentionally a + * SUBSET of the map — service-only methods with no REST route are not listed. + * + * @module route-handler/admin-rest-descriptors.test + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import { USER_METHODS } from "../dispatcher/handlers/user-dispatcher"; + +import { + dedupeRestOperations, + listAdminRestOperations, + restOperationsForService, + type AdminRestOperation, +} from "./admin-rest-descriptors"; + +describe("admin rest descriptors — users (reference service)", () => { + it("every users operation exists in the live USER_METHODS map", () => { + const liveMethods = new Set(Object.keys(USER_METHODS)); + const listed = restOperationsForService("users"); + // Subset direction: catches a listed operation the dispatcher no longer has. + // The reverse need not hold — internal methods have no route. + const orphans = listed.filter(op => !liveMethods.has(op.operation)); + expect(orphans).toEqual([]); + }); + + it("lists the /users CRUD with correct verbs, paths, and permissions", () => { + const ops = new Map( + restOperationsForService("users").map(op => [op.operation, op]) + ); + expect(ops.get("listUsers")).toMatchObject({ + method: "GET", + path: "/users", + permissionSlug: "read-users", + }); + expect(ops.get("createLocalUser")).toMatchObject({ + method: "POST", + path: "/users", + permissionSlug: "create-users", + }); + expect(ops.get("getUserById")).toMatchObject({ + method: "GET", + path: "/users/{userId}", + permissionSlug: "read-users", + }); + expect(ops.get("updateUser")).toMatchObject({ + method: "PATCH", + path: "/users/{userId}", + permissionSlug: "update-users", + }); + expect(ops.get("deleteUser")).toMatchObject({ + method: "DELETE", + path: "/users/{userId}", + permissionSlug: "delete-users", + }); + }); + + it("scopes /me routes to authenticated (current-user, no specific permission)", () => { + const me = restOperationsForService("users").filter(op => + op.path.startsWith("/me") + ); + expect(me.every(op => op.auth === "authenticated")).toBe(true); + expect(me.map(op => op.operation).sort()).toEqual([ + "getCurrentUser", + "getCurrentUserPermissions", + "updateCurrentUser", + ]); + }); + + it("does NOT list internal-only service methods (no REST route)", () => { + const ops = new Set( + restOperationsForService("users").map(op => op.operation) + ); + // These exist in USER_METHODS but have no parser route. If one gains a route + // later, add it to the table. + expect(ops.has("findByEmail")).toBe(false); + expect(ops.has("hasPassword")).toBe(false); + expect(ops.has("getUserPasswordHashById")).toBe(false); + }); +}); + +describe("admin rest descriptors — assembly", () => { + it("dedupes identical operation identities (dual-reachable appears once)", () => { + const a: AdminRestOperation = { + service: "users", + operation: "listUsers", + method: "GET", + path: "/users", + auth: "permission", + permissionSlug: "read-users", + tag: "Users", + envelope: "list", + }; + const deduped = dedupeRestOperations([a, { ...a }, { ...a, tag: "dup" }]); + expect(deduped).toHaveLength(1); + expect(deduped[0]).toEqual(a); + }); + + it("keeps operations that differ in path or verb", () => { + const base = { + service: "users", + operation: "getUserById", + method: "GET", + auth: "permission", + permissionSlug: "read-users", + tag: "Users", + envelope: "doc", + } as const; + const deduped = dedupeRestOperations([ + { ...base, path: "/users/{userId}" }, + { ...base, path: "/users/{userId}/accounts" }, + { ...base, method: "PATCH", path: "/users/{userId}" }, + ]); + expect(deduped).toHaveLength(3); + }); + + it("listAdminRestOperations is stable, non-empty, and internally unique", () => { + const a = listAdminRestOperations(); + const b = listAdminRestOperations(); + expect(a).toEqual(b); + expect(a.length).toBeGreaterThan(0); + const keys = a.map( + op => `${op.service}::${op.operation}::${op.method}::${op.path}` + ); + expect(new Set(keys).size).toBe(keys.length); + }); +}); diff --git a/packages/nextly/src/route-handler/admin-rest-descriptors.ts b/packages/nextly/src/route-handler/admin-rest-descriptors.ts new file mode 100644 index 0000000000..e1267d0177 --- /dev/null +++ b/packages/nextly/src/route-handler/admin-rest-descriptors.ts @@ -0,0 +1,1484 @@ +/** + * Admin REST operation introspection (the plugin-facing seam). + * + * `listAdminRestOperations()` answers one general question — "what REST + * operations does the admin catch-all expose?" — with no opinion about who + * consumes the answer. The OpenAPI docs plugin (@nextlyhq/plugin-api-docs) turns + * this into a spec; future tooling (client generators, health checks, permission + * auditors) can consume the same list without re-deriving it. + * + * The catch-all's routing is imperative across four layers (route-parser → + * dispatcher switch → per-domain method maps → direct-dispatch handlers), with + * no declarative table to iterate. Rather than refactor dispatch, this module is + * a read-only declarative view of the same operations, kept honest by an + * agreement test: every listed operation's name must exist in the live + * `*_METHODS` map for its service, so a renamed dispatcher method fails the test + * rather than silently producing a stale list. + * + * `path` is relative to the catch-all mount root with a leading slash and + * `{param}` placeholders (`/users/{userId}`); consumers join it with whatever + * mount the host app uses (commonly `/admin/api`). `envelope` names the + * canonical response shape (`api/response-shapes.ts`); `permissionSlug` may be a + * `{collectionName}` / `{slug}` template where the target is user-defined. + * + * @module route-handler/admin-rest-descriptors + * @since alpha + */ + +// ============================================================ +// Types +// ============================================================ + +/** Uppercase HTTP verb an operation is served under. */ +export type RestHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * How an operation is secured. + * - `public`: no authentication. + * - `authenticated`: a valid session/API key, but no specific permission. + * - `permission`: gated by an RBAC permission (`permissionSlug`). + */ +export type RestAuthMode = "public" | "authenticated" | "permission"; + +/** The canonical response envelope the operation answers with. */ +export type RestEnvelope = + "list" | "doc" | "mutation" | "action" | "data" | "total" | "bulk"; + +/** A single admin REST operation, as seen by introspection consumers. */ +export interface AdminRestOperation { + /** Service name (the dispatcher's `ServiceType`, e.g. "users"). */ + service: string; + /** The dispatcher method name — the operation identity + agreement key. */ + operation: string; + /** HTTP verb. */ + method: RestHttpMethod; + /** Path relative to the mount root, leading "/", `{param}` placeholders. */ + path: string; + /** How the operation is secured. */ + auth: RestAuthMode; + /** Required when `auth === "permission"`; an RBAC slug (may be templated). */ + permissionSlug?: string; + /** Grouping label for consumers that present a list (e.g. a tag). */ + tag: string; + /** The canonical response envelope kind. */ + envelope: RestEnvelope; +} + +// ============================================================ +// Table factory — keeps the per-service tables compact and uniform. +// ============================================================ + +type Row = [ + operation: string, + method: RestHttpMethod, + path: string, + auth: RestAuthMode, + permissionSlug: string | undefined, + envelope: RestEnvelope, +]; + +function ops( + service: string, + tag: string, + rows: readonly Row[] +): AdminRestOperation[] { + return rows.map( + ([operation, method, path, auth, permissionSlug, envelope]) => ({ + service, + operation, + method, + path, + auth, + permissionSlug: permissionSlug ?? undefined, + tag, + envelope, + }) + ); +} + +// ============================================================ +// users — verified against the live USER_METHODS map +// ============================================================ + +const users = ops("users", "Users", [ + ["getCurrentUser", "GET", "/me", "authenticated", undefined, "doc"], + ["updateCurrentUser", "PATCH", "/me", "authenticated", undefined, "mutation"], + [ + "getCurrentUserPermissions", + "GET", + "/me/permissions", + "authenticated", + undefined, + "data", + ], + ["listUsers", "GET", "/users", "permission", "read-users", "list"], + [ + "createLocalUser", + "POST", + "/users", + "permission", + "create-users", + "mutation", + ], + ["getUserById", "GET", "/users/{userId}", "permission", "read-users", "doc"], + [ + "updateUser", + "PATCH", + "/users/{userId}", + "permission", + "update-users", + "mutation", + ], + [ + "deleteUser", + "DELETE", + "/users/{userId}", + "permission", + "delete-users", + "mutation", + ], + [ + "updatePasswordHash", + "PATCH", + "/users/{userId}/password", + "permission", + "update-users", + "action", + ], + [ + "getAccounts", + "GET", + "/users/{userId}/accounts", + "permission", + "read-users", + "data", + ], + // resolveAuthorization derives the action from the verb: DELETE → delete-users. + [ + "unlinkAccountForUser", + "DELETE", + "/users/{userId}/accounts/{provider}/{providerAccountId}", + "permission", + "delete-users", + "action", + ], +]); + +// ============================================================ +// rbac — roles, permissions, user-role assignment +// ============================================================ + +const rbac = ops("rbac", "Roles & Permissions", [ + ["createRole", "POST", "/roles", "permission", "create-roles", "mutation"], + ["listRoles", "GET", "/roles", "permission", "read-roles", "list"], + ["getRoleById", "GET", "/roles/{roleId}", "permission", "read-roles", "doc"], + [ + "updateRole", + "PATCH", + "/roles/{roleId}", + "permission", + "update-roles", + "mutation", + ], + [ + "deleteRole", + "DELETE", + "/roles/{roleId}", + "permission", + "delete-roles", + "mutation", + ], + [ + "addRoleInheritance", + "POST", + "/roles/{parentRoleId}/children", + "permission", + "update-roles", + "action", + ], + [ + "listDescendantRoles", + "GET", + "/roles/{roleId}/children", + "permission", + "read-roles", + "data", + ], + [ + "removeRoleInheritance", + "DELETE", + "/roles/{parentRoleId}/children/{childRoleId}", + "permission", + "update-roles", + "action", + ], + [ + "listAncestorRoles", + "GET", + "/roles/{roleId}/parents", + "permission", + "read-roles", + "data", + ], + [ + "setRolePermissions", + "PATCH", + "/roles/{roleId}/permissions", + "permission", + "update-roles", + "action", + ], + [ + "addPermissionToRole", + "POST", + "/roles/{roleId}/permissions", + "permission", + "update-roles", + "action", + ], + [ + "listRolePermissions", + "GET", + "/roles/{roleId}/permissions", + "permission", + "read-roles", + "data", + ], + [ + "removePermissionFromRole", + "DELETE", + "/roles/{roleId}/permissions/{permissionId}", + "permission", + "update-roles", + "action", + ], + [ + "ensurePermission", + "POST", + "/permissions", + "permission", + "manage-permissions", + "mutation", + ], + [ + "listPermissions", + "GET", + "/permissions", + "permission", + "read-roles", + "list", + ], + [ + "getPermissionById", + "GET", + "/permissions/{permissionId}", + "permission", + "read-roles", + "doc", + ], + [ + "updatePermission", + "PATCH", + "/permissions/{permissionId}", + "permission", + "manage-permissions", + "mutation", + ], + [ + "deletePermissionById", + "DELETE", + "/permissions/{permissionId}", + "permission", + "manage-permissions", + "mutation", + ], + [ + "assignRoleToUser", + "POST", + "/users/{userId}/roles", + "permission", + "update-users", + "action", + ], + [ + "listUserRoles", + "GET", + "/users/{userId}/roles", + "permission", + "read-roles", + "data", + ], + [ + "unassignRoleFromUser", + "DELETE", + "/users/{userId}/roles/{roleId}", + "permission", + "update-users", + "action", + ], +]); + +// ============================================================ +// collections — definitions, entries, bulk, versions +// The {collectionName} slug drives the per-collection permission. +// ============================================================ + +const collections = ops("collections", "Collections", [ + [ + "listCollections", + "GET", + "/collections", + "authenticated", + undefined, + "list", + ], + [ + "createCollection", + "POST", + "/collections", + "permission", + "manage-settings", + "mutation", + ], + [ + "getCollection", + "GET", + "/collections/schema/{collectionName}", + "permission", + "read-{collectionName}", + "doc", + ], + [ + "updateCollection", + "PATCH", + "/collections/{collectionName}", + "permission", + "manage-settings", + "mutation", + ], + [ + "deleteCollection", + "DELETE", + "/collections/{collectionName}", + "permission", + "manage-settings", + "mutation", + ], + [ + "previewSchemaChanges", + "POST", + "/collections/schema/{collectionName}/preview", + "permission", + "manage-settings", + "data", + ], + [ + "applySchemaChanges", + "POST", + "/collections/schema/{collectionName}/apply", + "permission", + "manage-settings", + "action", + ], + [ + "listEntries", + "GET", + "/collections/{collectionName}/entries", + "permission", + "read-{collectionName}", + "list", + ], + [ + "createEntry", + "POST", + "/collections/{collectionName}/entries", + "permission", + "create-{collectionName}", + "mutation", + ], + [ + "bulkUpdateByQuery", + "PATCH", + "/collections/{collectionName}/entries", + "permission", + "update-{collectionName}", + "bulk", + ], + [ + "bulkDeleteEntries", + "POST", + "/collections/{collectionName}/entries/bulk-delete", + "permission", + "delete-{collectionName}", + "bulk", + ], + [ + "bulkUpdateEntries", + "POST", + "/collections/{collectionName}/entries/bulk-update", + "permission", + "update-{collectionName}", + "bulk", + ], + [ + "countEntries", + "GET", + "/collections/{collectionName}/entries/count", + "permission", + "read-{collectionName}", + "total", + ], + [ + "getEntry", + "GET", + "/collections/{collectionName}/entries/{entryId}", + "permission", + "read-{collectionName}", + "doc", + ], + [ + "updateEntry", + "PATCH", + "/collections/{collectionName}/entries/{entryId}", + "permission", + "update-{collectionName}", + "mutation", + ], + [ + "deleteEntry", + "DELETE", + "/collections/{collectionName}/entries/{entryId}", + "permission", + "delete-{collectionName}", + "mutation", + ], + [ + "duplicateEntry", + "POST", + "/collections/{collectionName}/entries/{entryId}/duplicate", + "permission", + "create-{collectionName}", + "mutation", + ], + [ + "publishAllLocales", + "POST", + "/collections/{collectionName}/entries/{entryId}/publish-all", + "permission", + "update-{collectionName}", + "mutation", + ], + [ + "listEntryVersions", + "GET", + "/collections/{collectionName}/entries/{entryId}/versions", + "permission", + "read-{collectionName}", + "list", + ], + [ + "getEntryVersion", + "GET", + "/collections/{collectionName}/entries/{entryId}/versions/{versionNo}", + "permission", + "read-{collectionName}", + "doc", + ], + [ + "getEntryVersionDiff", + "GET", + "/collections/{collectionName}/entries/{entryId}/versions/diff", + "permission", + "read-{collectionName}", + "doc", + ], + [ + "setEntryVersionLabel", + "PATCH", + "/collections/{collectionName}/entries/{entryId}/versions/{versionNo}", + "permission", + "update-{collectionName}", + "mutation", + ], + [ + "restoreEntryVersion", + "POST", + "/collections/{collectionName}/entries/{entryId}/versions/{versionNo}/restore", + "permission", + "update-{collectionName}", + "action", + ], + [ + "discardWorkingDraft", + "DELETE", + "/collections/{collectionName}/entries/{entryId}/versions/working-draft", + "permission", + "update-{collectionName}", + "mutation", + ], +]); + +// ============================================================ +// singles — documents, schema, versions +// ============================================================ + +const singles = ops("singles", "Singles", [ + ["listSingles", "GET", "/singles", "authenticated", undefined, "list"], + [ + "createSingle", + "POST", + "/singles", + "permission", + "manage-settings", + "mutation", + ], + [ + "getSingleDocument", + "GET", + "/singles/{slug}", + "permission", + "read-{slug}", + "doc", + ], + [ + "updateSingleDocument", + "PATCH", + "/singles/{slug}", + "permission", + "update-{slug}", + "mutation", + ], + [ + "deleteSingle", + "DELETE", + "/singles/{slug}", + "permission", + "manage-settings", + "action", + ], + [ + "getSingleSchema", + "GET", + "/singles/{slug}/schema", + "permission", + "read-{slug}", + "doc", + ], + [ + "updateSingleSchema", + "PATCH", + "/singles/{slug}/schema", + "permission", + "manage-settings", + "mutation", + ], + [ + "previewSingleSchemaChanges", + "POST", + "/singles/schema/{slug}/preview", + "permission", + "manage-settings", + "data", + ], + [ + "applySingleSchemaChanges", + "POST", + "/singles/schema/{slug}/apply", + "permission", + "manage-settings", + "action", + ], + [ + "listSingleVersions", + "GET", + "/singles/{slug}/versions", + "permission", + "read-{slug}", + "list", + ], + [ + "getSingleVersion", + "GET", + "/singles/{slug}/versions/{versionNo}", + "permission", + "read-{slug}", + "doc", + ], + [ + "getSingleVersionDiff", + "GET", + "/singles/{slug}/versions/diff", + "permission", + "read-{slug}", + "doc", + ], + [ + "setSingleVersionLabel", + "PATCH", + "/singles/{slug}/versions/{versionNo}", + "permission", + "update-{slug}", + "mutation", + ], + [ + "restoreSingleVersion", + "POST", + "/singles/{slug}/versions/{versionNo}/restore", + "permission", + "update-{slug}", + "action", + ], +]); + +// ============================================================ +// forms — public submission surface +// ============================================================ + +const forms = ops("forms", "Forms", [ + ["listForms", "GET", "/forms", "public", undefined, "list"], + ["getFormBySlug", "GET", "/forms/{slug}", "public", undefined, "doc"], + ["submitForm", "POST", "/forms/{slug}/submit", "public", undefined, "action"], +]); + +// ============================================================ +// field-groups (components) +// ============================================================ + +const fieldGroups = ops("field-groups", "Field Groups", [ + [ + "listComponents", + "GET", + "/field-groups", + "authenticated", + undefined, + "list", + ], + [ + "createComponent", + "POST", + "/field-groups", + "permission", + "manage-settings", + "mutation", + ], + [ + "previewComponentSchemaChanges", + "POST", + "/field-groups/schema/{slug}/preview", + "permission", + "manage-settings", + "data", + ], + [ + "applyComponentSchemaChanges", + "POST", + "/field-groups/schema/{slug}/apply", + "permission", + "manage-settings", + "action", + ], + [ + "getComponent", + "GET", + "/field-groups/{slug}", + "authenticated", + undefined, + "doc", + ], + [ + "updateComponent", + "PATCH", + "/field-groups/{slug}", + "permission", + "manage-settings", + "mutation", + ], + [ + "deleteComponent", + "DELETE", + "/field-groups/{slug}", + "permission", + "manage-settings", + "action", + ], +]); + +// ============================================================ +// email providers + templates, user fields +// ============================================================ + +const emailProviders = ops("emailProviders", "Email Providers", [ + [ + "listProviders", + "GET", + "/email-providers", + "permission", + "manage-email-providers", + "data", + ], + [ + "listProviderTypes", + "GET", + "/email-providers/types", + "permission", + "manage-email-providers", + "data", + ], + [ + "createProvider", + "POST", + "/email-providers", + "permission", + "manage-email-providers", + "mutation", + ], + [ + "getProvider", + "GET", + "/email-providers/{providerId}", + "permission", + "manage-email-providers", + "doc", + ], + [ + "updateProvider", + "PATCH", + "/email-providers/{providerId}", + "permission", + "manage-email-providers", + "mutation", + ], + [ + "deleteProvider", + "DELETE", + "/email-providers/{providerId}", + "permission", + "manage-email-providers", + "action", + ], + [ + "setDefault", + "PATCH", + "/email-providers/{providerId}/default", + "permission", + "manage-email-providers", + "action", + ], + [ + "testProvider", + "POST", + "/email-providers/{providerId}/test", + "permission", + "manage-email-providers", + "action", + ], +]); + +const emailTemplates = ops("emailTemplates", "Email Templates", [ + [ + "listTemplates", + "GET", + "/email-templates", + "permission", + "manage-email-templates", + "data", + ], + [ + "createTemplate", + "POST", + "/email-templates", + "permission", + "manage-email-templates", + "mutation", + ], + [ + "previewTemplate", + "POST", + "/email-templates/{templateId}/preview", + "permission", + "manage-email-templates", + "data", + ], + [ + "getTemplate", + "GET", + "/email-templates/{templateId}", + "permission", + "manage-email-templates", + "doc", + ], + [ + "updateTemplate", + "PATCH", + "/email-templates/{templateId}", + "permission", + "manage-email-templates", + "mutation", + ], + [ + "deleteTemplate", + "DELETE", + "/email-templates/{templateId}", + "permission", + "manage-email-templates", + "action", + ], +]); + +const userFields = ops("userFields", "User Fields", [ + [ + "listUserFields", + "GET", + "/user-fields", + "permission", + "manage-settings", + "data", + ], + [ + "createField", + "POST", + "/user-fields", + "permission", + "manage-settings", + "mutation", + ], + [ + "reorderFields", + "PATCH", + "/user-fields/reorder", + "permission", + "manage-settings", + "action", + ], + [ + "getField", + "GET", + "/user-fields/{fieldId}", + "permission", + "manage-settings", + "doc", + ], + [ + "updateField", + "PATCH", + "/user-fields/{fieldId}", + "permission", + "manage-settings", + "mutation", + ], + [ + "deleteField", + "DELETE", + "/user-fields/{fieldId}", + "permission", + "manage-settings", + "action", + ], +]); + +// ============================================================ +// apiKeys + webhooks — direct dispatch, umbrella update-* permission +// ============================================================ + +const apiKeys = ops("apiKeys", "API Keys", [ + ["listApiKeys", "GET", "/api-keys", "permission", "read-api-keys", "list"], + [ + "createApiKey", + "POST", + "/api-keys", + "permission", + "create-api-keys", + "mutation", + ], + [ + "getApiKeyById", + "GET", + "/api-keys/{apiKeyId}", + "permission", + "read-api-keys", + "doc", + ], + [ + "updateApiKey", + "PATCH", + "/api-keys/{apiKeyId}", + "permission", + "update-api-keys", + "mutation", + ], + [ + "revokeApiKey", + "DELETE", + "/api-keys/{apiKeyId}", + "permission", + "delete-api-keys", + "action", + ], +]); + +const webhooks = ops("webhooks", "Webhooks", [ + ["listWebhooks", "GET", "/webhooks", "permission", "read-webhooks", "list"], + [ + "createWebhook", + "POST", + "/webhooks", + "permission", + "create-webhooks", + "mutation", + ], + [ + "getWebhookById", + "GET", + "/webhooks/{webhookId}", + "permission", + "read-webhooks", + "doc", + ], + [ + "updateWebhook", + "PATCH", + "/webhooks/{webhookId}", + "permission", + "update-webhooks", + "mutation", + ], + [ + "deleteWebhook", + "DELETE", + "/webhooks/{webhookId}", + "permission", + "delete-webhooks", + "action", + ], + [ + "revealWebhookSecret", + "GET", + "/webhooks/{webhookId}/secret", + "permission", + "update-webhooks", + "data", + ], + [ + "rotateWebhookSecret", + "POST", + "/webhooks/{webhookId}/secret/rotate", + "permission", + "update-webhooks", + "mutation", + ], + [ + "expireWebhookOldSecrets", + "POST", + "/webhooks/{webhookId}/secret/expire-old", + "permission", + "update-webhooks", + "mutation", + ], + [ + "listWebhookDeliveries", + "GET", + "/webhooks/{webhookId}/deliveries", + "permission", + "read-webhooks", + "list", + ], + [ + "getWebhookDelivery", + "GET", + "/webhooks/{webhookId}/deliveries/{deliveryId}", + "permission", + "read-webhooks", + "doc", + ], + [ + "redeliverWebhookDelivery", + "POST", + "/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver", + "permission", + "update-webhooks", + "mutation", + ], + [ + "testWebhookEndpoint", + "POST", + "/webhooks/{webhookId}/test", + "permission", + "update-webhooks", + "action", + ], + [ + "drainWebhooks", + "POST", + "/webhooks/drain", + "permission", + "update-webhooks", + "mutation", + ], +]); + +// ============================================================ +// preview links, settings, image sizes, dashboard, schema, email, admin-meta +// ============================================================ + +const previewLinks = ops("previewLinks", "Preview Links", [ + [ + "mintPreviewLink", + "POST", + "/preview-links", + "permission", + "update-{collectionName}", + "mutation", + ], + [ + "revokePreviewLinks", + "POST", + "/preview-links/revoke", + "permission", + "manage-settings", + "mutation", + ], +]); + +const generalSettings = ops("generalSettings", "Settings", [ + [ + "getGeneralSettings", + "GET", + "/general-settings", + "permission", + "manage-settings", + "data", + ], + [ + "updateGeneralSettings", + "PATCH", + "/general-settings", + "permission", + "manage-settings", + "mutation", + ], +]); + +const imageSizes = ops("imageSizes", "Image Sizes", [ + [ + "imageSizes", + "GET", + "/image-sizes", + "permission", + "manage-settings", + "list", + ], + [ + "createImageSize", + "POST", + "/image-sizes", + "permission", + "manage-settings", + "mutation", + ], + [ + "getImageSizeById", + "GET", + "/image-sizes/{imageId}", + "permission", + "manage-settings", + "doc", + ], + [ + "updateImageSize", + "PATCH", + "/image-sizes/{imageId}", + "permission", + "manage-settings", + "mutation", + ], + [ + "deleteImageSize", + "DELETE", + "/image-sizes/{imageId}", + "permission", + "manage-settings", + "action", + ], +]); + +const dashboard = ops("dashboard", "Dashboard", [ + [ + "getDashboardStats", + "GET", + "/dashboard/stats", + "authenticated", + undefined, + "data", + ], + [ + "getDashboardRecentEntries", + "GET", + "/dashboard/recent-entries", + "authenticated", + undefined, + "data", + ], + [ + "getDashboardActivity", + "GET", + "/dashboard/activity", + "authenticated", + undefined, + "data", + ], +]); + +const schema = ops("schema", "Schema", [ + [ + "getSchemaJournal", + "GET", + "/schema/journal", + "authenticated", + undefined, + "data", + ], +]); + +const email = ops("email", "Email", [ + ["send", "POST", "/email/send", "authenticated", undefined, "action"], + [ + "sendWithTemplate", + "POST", + "/email/send-with-template", + "authenticated", + undefined, + "action", + ], +]); + +const adminMeta = ops("admin-meta", "Admin Meta", [ + ["getAdminMeta", "GET", "/admin-meta", "public", undefined, "data"], + [ + "updateAdminMetaSidebarGroups", + "PATCH", + "/admin-meta/sidebar-groups", + "permission", + "manage-settings", + "mutation", + ], +]); + +// ============================================================ +// auth — /admin/api/auth/* (public session surface; CSRF on writes) +// ============================================================ + +const auth = ops("auth", "Auth", [ + ["setup-status", "GET", "/auth/setup-status", "public", undefined, "data"], + ["session", "GET", "/auth/session", "public", undefined, "data"], + ["csrf", "GET", "/auth/csrf", "public", undefined, "data"], + ["ui", "GET", "/auth/ui", "public", undefined, "data"], + ["login", "POST", "/auth/login", "public", undefined, "data"], + [ + "challenge-resolve", + "POST", + "/auth/challenge/resolve", + "public", + undefined, + "data", + ], + ["logout", "POST", "/auth/logout", "public", undefined, "data"], + ["refresh", "POST", "/auth/refresh", "public", undefined, "data"], + ["setup", "POST", "/auth/setup", "public", undefined, "data"], + ["register", "POST", "/auth/register", "public", undefined, "data"], + [ + "forgot-password", + "POST", + "/auth/forgot-password", + "public", + undefined, + "data", + ], + [ + "reset-password", + "POST", + "/auth/reset-password", + "public", + undefined, + "data", + ], + ["accept-invite", "POST", "/auth/accept-invite", "public", undefined, "data"], + [ + "set-initial-password", + "POST", + "/auth/set-initial-password", + "public", + undefined, + "data", + ], + ["verify-email", "POST", "/auth/verify-email", "public", undefined, "data"], + [ + "verify-email-resend", + "POST", + "/auth/verify-email/resend", + "public", + undefined, + "data", + ], + [ + "change-password", + "PATCH", + "/auth/change-password", + "authenticated", + undefined, + "data", + ], +]); + +// ============================================================ +// Mounted standalone surfaces — media factory + health check +// +// The catch-all tables above cover the admin REST surface; these are the OTHER +// first-party surfaces a host app mounts as standalone route files: the media +// factory (`createMediaHandlers` — mounted twice: auth'd CRUD + public reads) +// and the health check. Operations are TEMPLATES relative to wherever the user +// mounted the handler — the consumer joins them with the mount path the +// filesystem scan discovered, and filters by the verbs the route file +// re-exports (which is exactly what distinguishes the two media mounts). +// +// Media auth is mount-dependent (reads are public on the public mount, +// permission-gated on the admin mount), so read ops carry no fixed auth — the +// consumer resolves it from the mount. Writes are admin-mount-only. +// ============================================================ + +/** Verbs a mounted standalone surface may use (health also answers HEAD). */ +export type MountedSurfaceMethod = + "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"; + +/** A media or health operation template. */ +export interface MountedSurfaceOperation { + /** Dispatcher-surface identity (operationId basis). */ + operation: string; + method: MountedSurfaceMethod; + /** Path relative to the mount root, `{param}` placeholders. */ + path: string; + /** The canonical response envelope kind. */ + envelope: RestEnvelope; + /** Admin-mount-only (uploads, mutations, deletes) — never on public mounts. */ + write: boolean; + /** Permission slug enforced on the ADMIN mount (reads: read-media, …). */ + adminPermission: string; + /** Short human summary. */ + summary: string; + /** Upload bodies are multipart/form-data, not JSON. */ + multipart?: boolean; +} + +const MEDIA_READ = "read-media"; + +/** + * The media factory's operations (paths relative to the media mount root). + * Read set mirrors the public route's documented surface; write set mirrors the + * auth'd mount (upload, update, delete, bulk delete, folder CRUD). + */ +export function listMediaSurfaceOperations(): MountedSurfaceOperation[] { + return [ + { + operation: "listMedia", + method: "GET", + path: "/", + envelope: "list", + write: false, + adminPermission: MEDIA_READ, + summary: "List media with pagination.", + }, + { + operation: "getMediaById", + method: "GET", + path: "/{id}", + envelope: "doc", + write: false, + adminPermission: MEDIA_READ, + summary: "Get a media file by id.", + }, + { + operation: "listMediaFolders", + method: "GET", + path: "/folders", + envelope: "list", + write: false, + adminPermission: MEDIA_READ, + summary: "List media folders.", + }, + { + operation: "getMediaFolder", + method: "GET", + path: "/folders/{id}", + envelope: "doc", + write: false, + adminPermission: MEDIA_READ, + summary: "Get a folder by id.", + }, + { + operation: "getMediaFolderContents", + method: "GET", + path: "/folders/{id}/contents", + envelope: "list", + write: false, + adminPermission: MEDIA_READ, + summary: "List a folder's contents.", + }, + { + operation: "getRootFolderContents", + method: "GET", + path: "/folders/root/contents", + envelope: "list", + write: false, + adminPermission: MEDIA_READ, + summary: "List the root folder's contents.", + }, + { + operation: "uploadMedia", + method: "POST", + path: "/", + envelope: "mutation", + write: true, + adminPermission: "create-media", + summary: "Upload a file (multipart/form-data).", + multipart: true, + }, + { + operation: "updateMedia", + method: "PATCH", + path: "/{id}", + envelope: "mutation", + write: true, + adminPermission: "update-media", + summary: "Update a media file's metadata.", + }, + { + operation: "deleteMedia", + method: "DELETE", + path: "/{id}", + envelope: "action", + write: true, + adminPermission: "delete-media", + summary: "Delete a media file.", + }, + { + operation: "bulkDeleteMedia", + method: "POST", + path: "/bulk-delete", + envelope: "bulk", + write: true, + adminPermission: "delete-media", + summary: "Bulk delete media files and folders.", + }, + { + operation: "createMediaFolder", + method: "POST", + path: "/folders", + envelope: "mutation", + write: true, + adminPermission: "manage-media", + summary: "Create a media folder.", + }, + { + operation: "updateMediaFolder", + method: "PATCH", + path: "/folders/{id}", + envelope: "mutation", + write: true, + adminPermission: "manage-media", + summary: "Rename / update a folder.", + }, + { + operation: "deleteMediaFolder", + method: "DELETE", + path: "/folders/{id}", + envelope: "action", + write: true, + adminPermission: "manage-media", + summary: "Delete a folder.", + }, + ]; +} + +/** The health check's operations (public, unauthenticated). */ +export function listHealthSurfaceOperations(): MountedSurfaceOperation[] { + return [ + { + operation: "health", + method: "GET", + path: "/", + envelope: "data", + write: false, + adminPermission: MEDIA_READ, + summary: "Liveness + database connectivity probe.", + }, + { + operation: "healthHead", + method: "HEAD", + path: "/", + envelope: "data", + write: false, + adminPermission: MEDIA_READ, + summary: "Liveness probe (headers only).", + }, + ]; +} + +// ============================================================ +// Assembly +// ============================================================ + +const SERVICE_TABLES: ReadonlyArray = [ + users, + rbac, + collections, + singles, + forms, + fieldGroups, + emailProviders, + emailTemplates, + userFields, + apiKeys, + webhooks, + previewLinks, + generalSettings, + imageSizes, + dashboard, + schema, + email, + adminMeta, + auth, +]; + +/** + * Collapse operations that agree on (service, operation, method, path) to the + * first occurrence — an operation reachable more than one way appears once. + */ +export function dedupeRestOperations( + operations: readonly AdminRestOperation[] +): AdminRestOperation[] { + const seen = new Set(); + const out: AdminRestOperation[] = []; + for (const op of operations) { + const key = `${op.service}::${op.operation}::${op.method}::${op.path}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(op); + } + return out; +} + +/** + * List every admin REST operation the catch-all exposes. Pure and deterministic + * so consumers (and tests) can call it freely, and deduped. + */ +export function listAdminRestOperations(): AdminRestOperation[] { + const all: AdminRestOperation[] = []; + for (const table of SERVICE_TABLES) all.push(...table); + return dedupeRestOperations(all); +} + +/** Operations for one service, or an empty array if it is not covered. */ +export function restOperationsForService( + service: string +): AdminRestOperation[] { + return listAdminRestOperations().filter(op => op.service === service); +} diff --git a/packages/nextly/src/route-handler/content-surfaces.ts b/packages/nextly/src/route-handler/content-surfaces.ts new file mode 100644 index 0000000000..f8b1630be4 --- /dev/null +++ b/packages/nextly/src/route-handler/content-surfaces.ts @@ -0,0 +1,110 @@ +/** + * Content-surface introspection (the plugin-facing seam). + * + * `listContentSurfaces()` answers "what collections and singles does THIS app + * have right now, and what fields do they carry?" — covering every origin: + * code-first (nextly.config), plugin-contributed, and dynamic ones created + * through the admin Schema Builder (persisted in the database). The registry + * services query the DB on every call, so a collection created a moment ago is + * already in the answer — the docs plugin uses this so dynamically created + * content appears in the spec without a restart. + * + * Fields are returned opaque (`unknown[]`): the consumer (the api-docs plugin) + * projects the wire-shaping options it cares about; core's full `FieldConfig` + * union stays out of the stable surface. + * + * @module route-handler/content-surfaces + * @since alpha + */ +import { container } from "../di/container"; + +/** One content surface (collection or single), as introspection consumers see it. */ +export interface ContentSurfaceInfo { + slug: string; + /** Display labels; singles carry a singular label only. */ + labels?: { singular?: string; plural?: string }; + /** The field configs (`FieldConfig[]` in core terms), opaque here. */ + fields: unknown[]; + /** Origin: `"code"`, `"ui"`, or `"plugin:"` — informational. */ + source?: string; +} + +/** All content surfaces the app currently has. */ +export interface ContentSurfaces { + collections: ContentSurfaceInfo[]; + singles: ContentSurfaceInfo[]; +} + +/** The minimal registry record shape this seam reads (structural). */ +interface RegistryRecord { + slug?: unknown; + labels?: unknown; + label?: unknown; + fields?: unknown; + source?: unknown; +} + +function labelsOf(record: RegistryRecord): ContentSurfaceInfo["labels"] { + const labels = record.labels as + { singular?: string; plural?: string } | undefined; + if (labels && typeof labels === "object") return labels; + // Singles persist a singular `label` string rather than a labels object. + const label = record.label; + return typeof label === "string" ? { singular: label } : undefined; +} + +function project(records: RegistryRecord[]): ContentSurfaceInfo[] { + return records + .filter((r): r is RegistryRecord => typeof r === "object" && r !== null) + .map(r => ({ + slug: typeof r.slug === "string" ? r.slug : "", + labels: labelsOf(r), + fields: Array.isArray(r.fields) ? r.fields : [], + source: typeof r.source === "string" ? r.source : undefined, + })) + .filter(r => r.slug.length > 0); +} + +/** + * Read one registry's surfaces, honoring the seam's "empty rather than error" + * contract. A missing container binding, a missing method, OR a registry that + * REJECTS (the services query the database on every call, so an unreachable + * database rejects) all degrade to an empty projection — the caller falls back + * to its own config view instead of the whole request failing. + */ +async function readSurfaces( + key: string, + read: (svc: T) => unknown +): Promise { + if (!container.has(key)) return []; + try { + const svc = container.get(key); + const records = (await read(svc)) as RegistryRecord[]; + return Array.isArray(records) ? project(records) : []; + } catch { + return []; + } +} + +/** + * List every registered collection and single with its fields. Reads the + * runtime registry services through the DI container; when DI has not run, + * a service is missing, or a registry cannot answer, both arrays are empty + * rather than an error — callers fall back to their own config view. + */ +export async function listContentSurfaces(): Promise { + return { + collections: await readSurfaces<{ + getAllCollections?: () => Promise; + }>("collectionRegistryService", svc => + typeof svc?.getAllCollections === "function" + ? svc.getAllCollections() + : [] + ), + singles: await readSurfaces<{ getAllSingles?: () => Promise }>( + "singleRegistryService", + svc => + typeof svc?.getAllSingles === "function" ? svc.getAllSingles() : [] + ), + }; +} diff --git a/packages/nextly/src/route-handler/request-auth.ts b/packages/nextly/src/route-handler/request-auth.ts new file mode 100644 index 0000000000..dd6e39da94 --- /dev/null +++ b/packages/nextly/src/route-handler/request-auth.ts @@ -0,0 +1,30 @@ +/** + * Request-auth introspection (the plugin-facing seam). + * + * `isAuthenticatedApiRequest(req)` answers "is THIS caller carrying a valid + * session cookie or API key?" — the same check the dispatcher's auth pipeline + * runs, exposed read-only so a plugin serving a PUBLIC route can distinguish an + * anonymous visitor from a logged-in one (e.g. the docs plugin showing the full + * spec to admins and the public-only spec to everyone else). A public route's + * context carries `user: null` by design, which is why the check reads the + * request itself. + * + * @module route-handler/request-auth + * @since alpha + */ +import { + isErrorResponse, + requireAuthentication, +} from "@nextly/auth/middleware"; + +/** + * Whether the request carries a valid session cookie or Bearer API key. + * Validation is the production pipeline's own (`requireAuthentication`) — no + * reimplementation to drift from it. + */ +export async function isAuthenticatedApiRequest( + req: Request +): Promise { + const result = await requireAuthentication(req); + return !isErrorResponse(result); +} diff --git a/packages/plugin-api-docs/.gitignore b/packages/plugin-api-docs/.gitignore new file mode 100644 index 0000000000..e7becd276d --- /dev/null +++ b/packages/plugin-api-docs/.gitignore @@ -0,0 +1,3 @@ +# Generated by scripts/vendor-scalar.mjs (build/check-types/test) from the +# @scalar/api-reference dependency — never edit or commit. +src/vendor/scalar-standalone.js.txt diff --git a/packages/plugin-api-docs/LICENSE b/packages/plugin-api-docs/LICENSE new file mode 100644 index 0000000000..5d219d9421 --- /dev/null +++ b/packages/plugin-api-docs/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 NextlyHQ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/plugin-api-docs/README.md b/packages/plugin-api-docs/README.md new file mode 100644 index 0000000000..5c0291b655 --- /dev/null +++ b/packages/plugin-api-docs/README.md @@ -0,0 +1,75 @@ +# @nextlyhq/plugin-api-docs + +> Nextly is in alpha. APIs may change before 1.0. + +First-party API documentation plugin for Nextly. **This plugin is the way to get OpenAPI docs** — install it and it does everything at request time: it scans your app's route files to discover where the API is mounted, pulls the admin REST operations through nextly's introspection seam, folds in every registered plugin's routes, assembles an OpenAPI 3.1 document (with the error component generated from the live error-code enum), and serves it plus an interactive **Scalar** reference. + +It is **opt-in** and **framework-agnostic** (zero `next`/`react` dependency): the docs page is plain HTML served over the plugin route surface. Scalar ships as a real dependency and is served by the plugin itself (`GET …/scalar.js`) — no runtime CDN fetch, so docs work offline, inside sandboxed webviews, and without leaking doc views to a third party. Core nextly ships only two small read-only introspection seams this consumes — all OpenAPI knowledge lives here. + +## Install + +```bash +npm install @nextlyhq/plugin-api-docs +``` + +## Usage + +Register the plugin in your Nextly config: + +```ts +import { defineConfig } from "nextly/config"; +import { apiDocsPlugin } from "@nextlyhq/plugin-api-docs"; + +export default defineConfig({ + plugins: [apiDocsPlugin()], +}); +``` + +This contributes — served directly at the **admin API root** (not the plugin namespace): + +- `GET /admin/api/docs` — an interactive Scalar reference (the docs page) +- `GET /admin/api/docs/spec.json` — the OpenAPI 3.1 document, generated on demand +- `GET /admin/api/docs/scalar.js` — the self-hosted Scalar bundle +- an **API Docs** entry in the admin sidebar linking to the docs page + +The spec route is **admin-gated by default** (secure by default, like every plugin route). Set `visibility: "public"` to let anyone fetch the spec JSON. + +## Options + +| Option | Default | Description | +| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `docsPath` | `"/docs"` | Base path under the admin API root — set it in nextly.config to move the whole surface (e.g. `"/api-docs"` → `/admin/api/api-docs`). | +| `visibility` | `"admin"` | `"public"` makes the spec JSON publicly readable. | +| `label` | `"API Docs"` | Sidebar label. | +| `title` | `"Nextly API"` | `info.title` in the generated document. | +| `mounts` | — | Explicit mount declarations that correct or add to the filesystem scan (for non-standard layouts). | +| `excludePaths` | — | Glob patterns dropping paths from the spec. | +| `excludeServices` | — | Service names whose operations are dropped. | +| `excludeErrorCodes` | — | Error codes dropped from the generated error component. | + +```ts +// Move the docs to /admin/api/api-docs: +apiDocsPlugin({ docsPath: "/api-docs" }); +``` + +```ts +apiDocsPlugin({ + visibility: "public", + title: "My Blog API", + excludeServices: ["apiKeys"], +}); +``` + +## How it works + +At request time the plugin combines three sources: + +1. **A filesystem scan** of your `app/` route files — where each nextly surface is mounted and which verbs it exports (this is how the media double-mount — auth'd CRUD vs public GET — is discovered automatically). +2. **The admin REST introspection seam** (`listAdminRestOperations` from nextly) — the catch-all's operations, verbs, paths, and auth modes. +3. **The plugin-route view** (`listPluginRoutes`) — every registered plugin's routes, derived with zero plugin action; a plugin can enrich its entries with an optional `openapi?` annotation (`{ summary, description, tags }`) on its routes. + +The error component is generated from the live `NEXTLY_ERROR_STATUS` enum (all codes, grouped by status, with `x-request-id` / `retry-after` response headers) — never hand-listed. + +## License + +MIT diff --git a/packages/plugin-api-docs/eslint.config.mjs b/packages/plugin-api-docs/eslint.config.mjs new file mode 100644 index 0000000000..00f073c7bd --- /dev/null +++ b/packages/plugin-api-docs/eslint.config.mjs @@ -0,0 +1,29 @@ +import { config } from "@nextlyhq/eslint-config/base"; + +export default [ + ...config, + { + ignores: [ + ".tsup/**", + "dist/**", + ".turbo/**", + "node_modules/**", + "tsup.config.{ts,js,mjs}", + ], + }, + { + // reason: build scripts use Node globals + files: ["*.config.{ts,js,mjs}"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + __dirname: "readonly", + __filename: "readonly", + require: "readonly", + module: "readonly", + Buffer: "readonly", + }, + }, + }, +]; diff --git a/packages/plugin-api-docs/package.json b/packages/plugin-api-docs/package.json new file mode 100644 index 0000000000..8ab147a09b --- /dev/null +++ b/packages/plugin-api-docs/package.json @@ -0,0 +1,78 @@ +{ + "name": "@nextlyhq/plugin-api-docs", + "version": "0.0.2-alpha.57", + "description": "First-party API documentation plugin for Nextly: serves an interactive Scalar API reference for your generated OpenAPI spec", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "vendor:scalar": "node scripts/vendor-scalar.mjs", + "build": "node scripts/vendor-scalar.mjs && tsup", + "dev": "node scripts/vendor-scalar.mjs && tsup src/index.ts --format esm --dts --watch", + "check-types": "node scripts/vendor-scalar.mjs && tsc --noEmit", + "lint": "node scripts/vendor-scalar.mjs && eslint . --max-warnings 0", + "lint:fix": "eslint . --fix", + "test": "node scripts/vendor-scalar.mjs && vitest run", + "test:watch": "node scripts/vendor-scalar.mjs && vitest" + }, + "peerDependencies": { + "@nextlyhq/plugin-sdk": "workspace:*", + "nextly": "0.0.2-alpha.57" + }, + "devDependencies": { + "@nextlyhq/eslint-config": "workspace:*", + "@nextlyhq/plugin-sdk": "workspace:*", + "@nextlyhq/tsconfig": "workspace:*", + "@types/node": "^20.19.17", + "nextly": "workspace:*", + "tsup": "^8.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "keywords": [ + "nextly", + "nextly-plugin", + "plugin", + "openapi", + "docs", + "api", + "scalar", + "cms", + "headless-cms", + "app-framework" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/nextlyhq/nextly.git", + "directory": "packages/plugin-api-docs" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "provenance": true + }, + "homepage": "https://nextlyhq.com", + "bugs": { + "url": "https://github.com/nextlyhq/nextly/issues" + }, + "author": "Nextly (https://nextlyhq.com)", + "dependencies": { + "@scalar/api-reference": "^1.65.1" + } +} diff --git a/packages/plugin-api-docs/scripts/vendor-scalar.mjs b/packages/plugin-api-docs/scripts/vendor-scalar.mjs new file mode 100644 index 0000000000..6125914047 --- /dev/null +++ b/packages/plugin-api-docs/scripts/vendor-scalar.mjs @@ -0,0 +1,38 @@ +/** + * Vendor the Scalar standalone browser bundle into src/vendor as a text asset. + * + * The docs route serves Scalar same-origin (no CDN). The bundle is imported as + * a STRING at build time rather than resolved at runtime: the plugin's dist is + * pulled into the host app's Next bundle, where a bundler statically analyzes + * any literal require.resolve("@scalar/...") and fails on it, and where + * import.meta.url points into the bundle, not the package — runtime resolution + * cannot survive that. A text import is inert data to every bundler. + * + * Run by build/check-types/test so the generated asset exists wherever the + * module graph needs it. Fails loudly when the dependency is missing. + */ +import { copyFileSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(join(pkgRoot, "package.json")); + +let pkgDir; +try { + pkgDir = dirname(require.resolve("@scalar/api-reference/package.json")); +} catch { + // The exports map does not expose the manifest; walk up from the main entry. + let dir = dirname(require.resolve("@scalar/api-reference")); + const { existsSync } = await import("node:fs"); + while (!existsSync(join(dir, "package.json"))) dir = dirname(dir); + pkgDir = dir; +} + +const source = join(pkgDir, "dist", "browser", "standalone.js"); +const target = join(pkgRoot, "src", "vendor", "scalar-standalone.js.txt"); + +mkdirSync(dirname(target), { recursive: true }); +copyFileSync(source, target); +console.log(`vendored Scalar bundle → ${target} (from ${source})`); diff --git a/packages/plugin-api-docs/src/__tests__/excludes.test.ts b/packages/plugin-api-docs/src/__tests__/excludes.test.ts new file mode 100644 index 0000000000..129fdf1d7b --- /dev/null +++ b/packages/plugin-api-docs/src/__tests__/excludes.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for the typed spec excludes. + * + * @module __tests__/excludes + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import { applyExcludes, excludeOperationsByService } from "../excludes"; +import type { DocsOperation } from "../descriptors"; +import { generateOpenApiDocument } from "../generate"; +import type { ScanResult } from "../scan"; + +const scan = (): ScanResult => ({ + routes: [ + { + mountPath: "/admin/api/[[...params]]", + source: { kind: "dynamic-catchall" }, + verbs: ["GET"], + }, + ], + unrecognized: [], +}); + +const ops: readonly DocsOperation[] = [ + { + service: "users", + operation: "listUsers", + method: "GET", + path: "/users", + auth: "permission", + permissionSlug: "read-users", + tag: "Users", + }, + { + service: "users", + operation: "getCurrentUser", + method: "GET", + path: "/me", + auth: "authenticated", + tag: "Users", + }, +]; + +describe("excludeOperationsByService", () => { + it("drops a named service's operations", () => { + const kept = excludeOperationsByService(ops, ["users"]); + expect(kept).toHaveLength(0); + }); + + it("keeps everything when no services are named", () => { + expect(excludeOperationsByService(ops, undefined)).toHaveLength(2); + expect(excludeOperationsByService(ops, [])).toHaveLength(2); + }); +}); + +describe("applyExcludes", () => { + const baseDoc = () => + generateOpenApiDocument({ scan: scan(), restOperations: ops }); + + it("drops paths matching an excludePaths glob", () => { + const doc = applyExcludes(baseDoc(), { excludePaths: ["**/me"] }); + const paths = Object.keys((doc.paths ?? {}) as Record); + expect(paths.some(p => p.endsWith("/me"))).toBe(false); + expect(paths.some(p => p.includes("/users"))).toBe(true); + }); + + it("drops error codes named in excludeErrorCodes from the enum", () => { + const doc = applyExcludes(baseDoc(), { + excludeErrorCodes: ["NOT_FOUND", "FORBIDDEN"], + }); + const components = doc.components as Record; + const schemas = components.schemas as Record; + const errorResponse = schemas.ErrorResponse as Record; + const error = (errorResponse.properties as Record) + .error as Record; + const codeProp = (error.properties as Record).code as { + enum: string[]; + }; + expect(codeProp.enum).not.toContain("NOT_FOUND"); + expect(codeProp.enum).not.toContain("FORBIDDEN"); + // Other codes survive. + expect(codeProp.enum).toContain("VALIDATION_ERROR"); + }); + + it("returns the document untouched when no excludes are set", () => { + const doc = baseDoc(); + expect(applyExcludes(doc, {})).toBe(doc); + }); +}); diff --git a/packages/plugin-api-docs/src/__tests__/generate.test.ts b/packages/plugin-api-docs/src/__tests__/generate.test.ts new file mode 100644 index 0000000000..144230d36e --- /dev/null +++ b/packages/plugin-api-docs/src/__tests__/generate.test.ts @@ -0,0 +1,386 @@ +/** + * Tests for the OpenAPI document generator. + * + * Feeds a synthetic scan + operations shaped like the core seam's output, so the + * assembly is exercised end-to-end without booting the app. The headline + * invariant is the "generated from the enum" rule: every live + * `NEXTLY_ERROR_STATUS` code must appear in the spec's `ErrorResponse.code` + * enum — asserted against the live enum, never a hardcoded count. + * + * @module __tests__/generate + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import { NEXTLY_ERROR_STATUS } from "@nextlyhq/plugin-sdk"; + +import type { DocsOperation } from "../descriptors"; +import { generateOpenApiDocument } from "../generate"; +import type { ScanResult } from "../scan"; + +const scan = (): ScanResult => ({ + routes: [ + { + mountPath: "/admin/api/[[...params]]", + source: { kind: "dynamic-catchall" }, + verbs: ["GET", "POST", "PATCH", "DELETE"], + }, + { + mountPath: "/admin/api/media/[[...path]]", + source: { kind: "media" }, + verbs: ["GET", "POST", "PATCH", "DELETE"], + }, + { + mountPath: "/api/media/[[...path]]", + source: { kind: "media" }, + verbs: ["GET"], + }, + { + mountPath: "/api/health", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET", "HEAD"], + }, + ], + unrecognized: [], +}); + +// Synthetic admin REST operations, shaped exactly like the core seam's output. +const restOps: readonly DocsOperation[] = [ + { + service: "users", + operation: "getCurrentUser", + method: "GET", + path: "/me", + auth: "authenticated", + tag: "Users", + }, + { + service: "users", + operation: "listUsers", + method: "GET", + path: "/users", + auth: "permission", + permissionSlug: "read-users", + tag: "Users", + }, + { + service: "users", + operation: "createLocalUser", + method: "POST", + path: "/users", + auth: "permission", + permissionSlug: "create-users", + tag: "Users", + }, + { + service: "users", + operation: "getUserById", + method: "GET", + path: "/users/{userId}", + auth: "permission", + permissionSlug: "read-users", + tag: "Users", + }, + { + service: "users", + operation: "updateUser", + method: "PATCH", + path: "/users/{userId}", + auth: "permission", + permissionSlug: "update-users", + tag: "Users", + }, + { + service: "users", + operation: "deleteUser", + method: "DELETE", + path: "/users/{userId}", + auth: "permission", + permissionSlug: "delete-users", + tag: "Users", + }, +]; + +const doc = (): Record => + generateOpenApiDocument({ scan: scan(), restOperations: restOps }); + +describe("generateOpenApiDocument — structure", () => { + it("produces an OpenAPI 3.1.0 document with info and components", () => { + const d = doc(); + expect(d.openapi).toBe("3.1.0"); + expect(d.info).toEqual({ title: "Nextly API", version: "0.0.0" }); + expect(d.components).toBeDefined(); + }); + + it("joins operations with the scanned mount base", () => { + const paths = (doc().paths ?? {}) as Record< + string, + Record + >; + expect(paths["/admin/api/users"]?.get).toBeDefined(); + expect(paths["/admin/api/users"]?.post).toBeDefined(); + expect(paths["/admin/api/users/{userId}"]?.get).toBeDefined(); + expect(paths["/admin/api/users/{userId}"]?.patch).toBeDefined(); + expect(paths["/admin/api/users/{userId}"]?.delete).toBeDefined(); + expect(paths["/admin/api/me"]?.get).toBeDefined(); + }); + + it("attaches cookie + bearer security to a permission-gated operation", () => { + const paths = (doc().paths ?? {}) as Record< + string, + Record + >; + const get = paths["/admin/api/users"]?.get as + Record | undefined; + expect(get?.security).toEqual([{ cookieAuth: [] }, { bearerAuth: [] }]); + expect(get?.["x-nextly-permission"]).toBe("read-users"); + }); +}); + +describe("generateOpenApiDocument — errors generated from the live enum", () => { + it("lists EVERY live error code in ErrorResponse.code (no hardcoded list)", () => { + const components = doc().components as Record; + const schemas = components.schemas as Record; + const errorResponse = schemas.ErrorResponse as Record; + const error = (errorResponse.properties as Record) + .error as Record; + const codeProp = (error.properties as Record).code as { + enum: string[]; + }; + + const liveCodes = Object.keys(NEXTLY_ERROR_STATUS); + expect(codeProp.enum.sort()).toEqual([...liveCodes].sort()); + }); + + it("emits one response entry per HTTP status the enum defines", () => { + const components = doc().components as Record; + const responses = components.responses as Record; + for (const status of new Set(Object.values(NEXTLY_ERROR_STATUS))) { + expect(responses[String(status)]).toBeDefined(); + } + }); + + it("documents x-request-id on every response and retry-after on 429 only", () => { + const components = doc().components as Record; + const responses = components.responses as Record< + string, + Record + >; + for (const entry of Object.values(responses)) { + const headers = entry.headers as Record | undefined; + expect(headers?.["x-request-id"]).toBeDefined(); + } + expect( + (responses["429"]?.headers as Record)?.["retry-after"] + ).toBeDefined(); + expect( + (responses["400"]?.headers as Record)?.["retry-after"] + ).toBeUndefined(); + }); +}); + +describe("generateOpenApiDocument — public mounted surfaces (media + health)", () => { + it("documents the admin media mount with reads AND writes", () => { + const paths = (doc().paths ?? {}) as Record< + string, + Record + >; + // Reads + expect(paths["/admin/api/media"]?.get).toBeDefined(); + expect(paths["/admin/api/media/folders/root/contents"]?.get).toBeDefined(); + // Writes, gated by the scanned verbs ([GET,POST,PATCH,DELETE]) + expect(paths["/admin/api/media"]?.post).toBeDefined(); + expect(paths["/admin/api/media/{id}"]?.patch).toBeDefined(); + expect(paths["/admin/api/media/{id}"]?.delete).toBeDefined(); + // Upload is multipart, and the admin mount carries the media permission. + const upload = paths["/admin/api/media"]?.post as Record; + const body = upload.requestBody as Record; + expect(Object.keys(body.content as object)).toContain( + "multipart/form-data" + ); + expect(upload["x-nextly-permission"]).toBe("create-media"); + }); + + it("documents the PUBLIC media mount with GET-only reads, no writes", () => { + const paths = (doc().paths ?? {}) as Record< + string, + Record + >; + expect(paths["/api/media"]?.get).toBeDefined(); + expect(paths["/api/media/folders"]?.get).toBeDefined(); + // The public mount re-exports only GET — writes must NOT appear. + expect(paths["/api/media"]?.post).toBeUndefined(); + expect(paths["/api/media/{id}"]?.delete).toBeUndefined(); + // Reads on the public mount are public (no security array entries). + const list = paths["/api/media"]?.get as Record; + expect(list.security).toEqual([]); + expect(paths["/api/media"]?.get).toMatchObject({ + tags: ["Media (Public)"], + }); + }); + + it("documents the public health check", () => { + const paths = (doc().paths ?? {}) as Record< + string, + Record + >; + expect(paths["/api/health"]?.get).toBeDefined(); + expect(paths["/api/health"]?.head).toBeDefined(); + const get = paths["/api/health"]?.get as Record; + expect(get.security).toEqual([]); + expect(get.tags).toEqual(["Health"]); + }); +}); + +describe("generateOpenApiDocument — security schemes", () => { + it("declares cookieAuth and bearerAuth", () => { + const components = doc().components as Record; + const schemes = components.securitySchemes as Record; + expect((schemes.cookieAuth as Record).type).toBe("apiKey"); + expect((schemes.bearerAuth as Record).scheme).toBe( + "bearer" + ); + }); +}); + +describe("generateOpenApiDocument — publicOnly (anonymous viewer)", () => { + it("keeps ONLY public operations and drops every gated one", () => { + const d = generateOpenApiDocument({ + scan: scan(), + restOperations: restOps, + publicOnly: true, + }) as Record; + const paths = (d.paths ?? {}) as Record>; + // Public seam ops survive (none in this fixture — restOps are all gated), + // gated ops are gone... + expect(paths["/admin/api/users"]).toBeUndefined(); + expect(paths["/admin/api/me"]).toBeUndefined(); + // ...public mounted surfaces survive... + expect(paths["/api/health"]?.get).toBeDefined(); + expect(paths["/api/media"]?.get).toBeDefined(); + // ...and the gated admin media mount is dropped entirely. + expect(paths["/admin/api/media"]?.get).toBeUndefined(); + expect(paths["/admin/api/media"]?.post).toBeUndefined(); + // No gated op anywhere in the document. + for (const item of Object.values(paths)) { + for (const op of Object.values(item)) { + const sec = JSON.stringify( + (op as Record)?.security ?? "[]" + ); + if (sec === "[]") continue; + throw new Error( + "gated operation leaked into the public-only spec: " + + JSON.stringify(op).slice(0, 80) + ); + } + } + }); +}); + +describe("generateOpenApiDocument — review regressions", () => { + it("declares every {param} path segment as a required path parameter", () => { + const d = generateOpenApiDocument({ + scan: scan(), + restOperations: restOps, + }); + const paths = (d.paths ?? {}) as Record>; + let checked = 0; + for (const [path, item] of Object.entries(paths)) { + const templateNames = [...path.matchAll(/\{([A-Za-z_][\w]*)\}/g)].map( + m => m[1] + ); + if (templateNames.length === 0) continue; + for (const [verb, op] of Object.entries(item)) { + if (!["get", "post", "put", "patch", "delete", "head"].includes(verb)) + continue; + const params = (op as Record)?.parameters as + Array<{ name: string; in: string; required: boolean }> | undefined; + expect(params, `${verb} ${path} must declare parameters`).toBeDefined(); + for (const name of templateNames) { + const match = params?.find(p => p.name === name); + expect(match, `${verb} ${path} param ${name}`).toMatchObject({ + in: "path", + required: true, + }); + checked++; + } + } + } + // Positive control: the fixture has templated paths, so the loop ran. + expect(checked).toBeGreaterThan(0); + }); + + it("keeps operationIds unique when both media mounts exist", () => { + const d = generateOpenApiDocument({ + scan: scan(), + restOperations: restOps, + }); + const ids: string[] = []; + for (const item of Object.values( + d.paths as Record> + )) { + for (const op of Object.values(item)) { + const id = (op as Record)?.operationId; + if (typeof id === "string") ids.push(id); + } + } + expect(new Set(ids).size).toBe(ids.length); + // The two mounts qualify the same base name. + expect(ids).toContain("listMedia.admin"); + expect(ids).toContain("listMedia.public"); + }); + + it("never emits a write operation on a public media mount, even if the scan reports the verb", () => { + const hostileScan: ScanResult = { + routes: [ + { + mountPath: "/api/media/[[...path]]", + source: { kind: "media" }, + // A mis-declared public mount claiming POST — writes must still not appear. + verbs: ["GET", "POST", "DELETE"], + }, + ], + unrecognized: [], + }; + const d = generateOpenApiDocument({ scan: hostileScan }); + const paths = (d.paths ?? {}) as Record>; + expect(paths["/api/media"]?.get).toBeDefined(); + expect(paths["/api/media"]?.post).toBeUndefined(); + expect(paths["/api/media/{id}"]?.delete).toBeUndefined(); + }); + + it("keeps the templated operation when a content kind has zero surfaces (no silent drop)", () => { + // A templated collection op in the input; the users-only restOps fixture + // has none, so supply one representative op directly. + const collectionList: DocsOperation = { + service: "collections", + operation: "listEntries", + method: "GET", + path: "/collections/{collectionName}/entries", + auth: "permission", + permissionSlug: "read-{collectionName}", + tag: "Collections", + envelope: "list", + }; + const singleGet: DocsOperation = { + service: "singles", + operation: "getSingleDocument", + method: "GET", + path: "/singles/{slug}", + auth: "permission", + permissionSlug: "read-{slug}", + tag: "Singles", + envelope: "doc", + }; + const d = generateOpenApiDocument({ + scan: scan(), + restOperations: [...restOps, collectionList, singleGet], + content: { collections: [], singles: [{ slug: "homepage", fields: [] }] }, + }); + const paths = (d.paths ?? {}) as Record>; + expect( + paths["/admin/api/collections/{collectionName}/entries"]?.get + ).toBeDefined(); + expect(paths["/admin/api/singles/homepage"]?.get).toBeDefined(); + }); +}); diff --git a/packages/plugin-api-docs/src/__tests__/mount-overrides.test.ts b/packages/plugin-api-docs/src/__tests__/mount-overrides.test.ts new file mode 100644 index 0000000000..8baf810fdb --- /dev/null +++ b/packages/plugin-api-docs/src/__tests__/mount-overrides.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the mount-override merge. Pure: a synthetic scan result, so the + * replace/add/dedup semantics are pinned without touching the filesystem. + * + * @module __tests__/mount-overrides + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import { applyMountOverrides } from "../mount-overrides"; + +import type { ScanResult } from "../scan"; + +const scan = (): ScanResult => ({ + routes: [ + { + filePath: "/app/admin/api/[[...params]]/route.ts", + mountPath: "/admin/api/[[...params]]", + source: { kind: "dynamic-catchall" }, + verbs: ["GET", "POST", "PATCH", "DELETE"], + }, + { + filePath: "/app/api/health/route.ts", + mountPath: "/api/health", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET", "HEAD"], + }, + ], + unrecognized: [], +}); + +describe("applyMountOverrides", () => { + it("returns the scan unchanged when no overrides are given", () => { + expect(applyMountOverrides(scan())).toEqual(scan()); + expect(applyMountOverrides(scan(), [])).toEqual(scan()); + }); + + it("replaces a scanned mount's source and verbs, keeping its filePath", () => { + const result = applyMountOverrides(scan(), [ + { + mountPath: "/api/health", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET"], + }, + ]); + const health = result.routes.find(r => r.mountPath === "/api/health"); + expect(health?.verbs).toEqual(["GET"]); + // The override corrected the verbs but the mount still traces to its file. + expect(health?.filePath).toBe("/app/api/health/route.ts"); + expect(result.routes).toHaveLength(2); + }); + + it("adds an override whose mountPath the scan did not find", () => { + const result = applyMountOverrides(scan(), [ + { + mountPath: "/api/custom-webhook", + source: { kind: "api-subpath", subpath: "email-send" }, + verbs: ["POST"], + }, + ]); + const added = result.routes.find( + r => r.mountPath === "/api/custom-webhook" + ); + expect(added?.source).toEqual({ + kind: "api-subpath", + subpath: "email-send", + }); + // A declared-only mount has no source file. + expect(added?.filePath).toBeUndefined(); + expect(result.routes).toHaveLength(3); + }); + + it("lets the last duplicate override path win (deterministic)", () => { + const result = applyMountOverrides(scan(), [ + { + mountPath: "/api/health", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET"], + }, + { + mountPath: "/api/health", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["HEAD"], + }, + ]); + const health = result.routes.find(r => r.mountPath === "/api/health"); + expect(health?.verbs).toEqual(["HEAD"]); + expect( + result.routes.filter(r => r.mountPath === "/api/health") + ).toHaveLength(1); + }); + + it("passes the unrecognized list through untouched", () => { + const withUnrecognized: ScanResult = { + ...scan(), + unrecognized: [{ filePath: "app/api/odd/route.ts", reason: "unknown" }], + }; + const result = applyMountOverrides(withUnrecognized, [ + { + mountPath: "/api/new", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET"], + }, + ]); + expect(result.unrecognized).toEqual(withUnrecognized.unrecognized); + }); +}); diff --git a/packages/plugin-api-docs/src/__tests__/plugin-routes.test.ts b/packages/plugin-api-docs/src/__tests__/plugin-routes.test.ts new file mode 100644 index 0000000000..aea06ae1ea --- /dev/null +++ b/packages/plugin-api-docs/src/__tests__/plugin-routes.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for plugin-route derivation from the sdk's read-only view. + * + * @module __tests__/plugin-routes + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import type { PluginRouteInfo } from "@nextlyhq/plugin-sdk"; + +import { pluginRoutesToDocs } from "../plugin-routes"; + +const info = ( + over: Partial<{ + pluginName: string; + method: PluginRouteInfo["method"]; + path: string; + public: boolean; + requiredPermission: string; + }> & { + openapi?: { summary?: string; description?: string; tags?: string[] }; + } +): PluginRouteInfo => { + const pluginName = over.pluginName ?? "form-builder"; + const path = over.path ?? "/submit"; + return { + pluginName, + method: over.method ?? "POST", + path, + fullPath: `/plugins/${pluginName}${path}`, + public: over.public ?? false, + requiredPermission: over.requiredPermission, + openapi: over.openapi, + }; +}; + +describe("pluginRoutesToDocs", () => { + it("marks a public route public with no permission slug", () => { + const [d] = pluginRoutesToDocs([info({ public: true })]); + expect(d.auth).toBe("public"); + expect(d.permissionSlug).toBeUndefined(); + }); + + it("marks a route with requiredPermission as permission-gated", () => { + const [d] = pluginRoutesToDocs([ + info({ + pluginName: "seo", + method: "GET", + path: "/sitemap", + requiredPermission: "read-settings", + }), + ]); + expect(d.auth).toBe("permission"); + expect(d.permissionSlug).toBe("read-settings"); + }); + + it("marks a secure-by-default route (no flags) as authenticated", () => { + const [d] = pluginRoutesToDocs([ + info({ pluginName: "seo", method: "GET", path: "/stats" }), + ]); + expect(d.auth).toBe("authenticated"); + expect(d.permissionSlug).toBeUndefined(); + }); + + it("converts :param segments to OpenAPI {param} templates", () => { + const [d] = pluginRoutesToDocs([ + info({ method: "DELETE", path: "/forms/:id/fields/:field" }), + ]); + expect(d.path).toBe("/plugins/form-builder/forms/{id}/fields/{field}"); + }); + + it("produces stable, unique operation ids across plugins and verbs", () => { + const docs = pluginRoutesToDocs([ + info({ pluginName: "seo", method: "GET", path: "/sitemap" }), + info({ pluginName: "seo", method: "POST", path: "/sitemap" }), + info({ pluginName: "form-builder", method: "GET", path: "/submit" }), + ]); + const ids = docs.map(d => d.operation); + expect(new Set(ids).size).toBe(ids.length); + expect(docs[0]?.operation).toBe("seo.get.sitemap"); + expect(docs[1]?.operation).toBe("seo.post.sitemap"); + expect(docs[2]?.operation).toBe("form-builder.get.submit"); + }); + + it("folds an optional openapi annotation into the operation", () => { + const [d] = pluginRoutesToDocs([ + info({ + pluginName: "seo", + method: "GET", + path: "/sitemap", + openapi: { + summary: "Sitemap", + description: "XML sitemap of published entries.", + tags: ["search"], + }, + }), + ]); + expect(d.summary).toBe("Sitemap"); + expect(d.description).toBe("XML sitemap of published entries."); + expect(d.tags).toEqual(["search"]); + }); + + it("omits annotation fields when no openapi block is present", () => { + const [d] = pluginRoutesToDocs([ + info({ pluginName: "seo", method: "GET", path: "/sitemap" }), + ]); + expect(d.summary).toBeUndefined(); + expect(d.description).toBeUndefined(); + expect(d.tags).toBeUndefined(); + }); + + it("tags every operation with its plugin", () => { + const [d] = pluginRoutesToDocs([ + info({ pluginName: "seo", method: "GET", path: "/sitemap" }), + ]); + expect(d.tag).toBe("Plugin: seo"); + expect(d.service).toBe("plugins"); + }); +}); diff --git a/packages/plugin-api-docs/src/__tests__/plugin.test.ts b/packages/plugin-api-docs/src/__tests__/plugin.test.ts new file mode 100644 index 0000000000..fd2ab25763 --- /dev/null +++ b/packages/plugin-api-docs/src/__tests__/plugin.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for the API Docs plugin definition. + * + * Pins the contributed routes' configuration (paths, verbs, and the + * visibility-driven `public` flag — the plugin's security gate) and the pure + * Scalar page renderer. The spec handler is a thin wrapper over + * `generateOpenApiDocument` (covered by its own tests) fed by the sdk seams. + * + * @module __tests__/plugin + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import { apiDocsPlugin, renderDocsHtml } from "../plugin"; +import type { PluginRoute } from "@nextlyhq/plugin-sdk"; + +const routesOf = (visibility?: "admin" | "public"): readonly PluginRoute[] => + apiDocsPlugin(visibility ? { visibility } : undefined).contributes?.routes ?? + []; + +describe("apiDocsPlugin routes", () => { + it("contributes GET /docs, /docs/spec.json, /docs/scalar.js at the admin API root", () => { + const routes = routesOf(); + const paths = routes.map(r => r.path).sort(); + expect(paths).toEqual(["/docs", "/docs/scalar.js", "/docs/spec.json"]); + expect(routes.every(r => r.method === "GET")).toBe(true); + // First-party surface: mounted at the admin API root, not the plugin namespace. + expect(routes.every(r => r.mount === "admin-api")).toBe(true); + }); + + it("admin-gates the spec by default (no `public` flag)", () => { + const spec = routesOf().find(r => r.path === "/docs/spec.json"); + expect(spec?.public).toBeFalsy(); + }); + + it("admin-gates the whole surface by default (no `public` flags)", () => { + for (const r of routesOf()) expect(r.public).toBeFalsy(); + }); + + it("publishes the WHOLE surface (page + spec + bundle) when visibility is 'public'", () => { + // A logged-out visitor must reach the page, the spec it reads, and the + // Scalar bundle it loads — publishing only the JSON leaves nothing readable. + for (const r of routesOf("public")) expect(r.public).toBe(true); + }); + + it("keeps the docs page free of a permission requirement (it self-gates)", () => { + const docs = routesOf().find(r => r.path === "/docs"); + expect(docs?.requiredPermission).toBeUndefined(); + }); + + it("contributes a sidebar menu entry that links to /admin/api/docs", () => { + const item = apiDocsPlugin().contributes?.admin?.menu?.[0]; + expect(item?.label).toBe("API Docs"); + expect(item?.to).toBe("/admin/api/docs"); + expect(item?.icon).toBe("BookOpen"); + }); + + it("moves the whole surface with a custom docsPath (configured in nextly.config)", () => { + const routes = + apiDocsPlugin({ docsPath: "/api-docs" }).contributes?.routes ?? []; + const paths = routes.map(r => r.path).sort(); + expect(paths).toEqual([ + "/api-docs", + "/api-docs/scalar.js", + "/api-docs/spec.json", + ]); + const item = apiDocsPlugin({ docsPath: "/api-docs" }).contributes?.admin + ?.menu?.[0]; + expect(item?.to).toBe("/admin/api/api-docs"); + }); + + it("refuses a docsPath without a leading slash", () => { + expect(() => apiDocsPlugin({ docsPath: "docs" })).toThrow(/docsPath/); + }); + + it("honors a custom label", () => { + const item = apiDocsPlugin({ label: "Reference" }).contributes?.admin + ?.menu?.[0]; + expect(item?.label).toBe("Reference"); + }); +}); + +describe("renderDocsHtml", () => { + it("returns an HTML document loading the plugin-served Scalar bundle and pointing at the spec route", () => { + const html = renderDocsHtml( + "/admin/api/docs/spec.json", + "/admin/api/docs/scalar.js" + ); + expect(html).toContain(""); + // The bundle is served same-origin by the plugin — no CDN reference. + expect(html).not.toContain("cdn.jsdelivr.net"); + expect(html).toContain('data-url="/admin/api/docs/spec.json"'); + expect(html).toContain('src="/admin/api/docs/scalar.js"'); + }); + + it("escapes a double quote in the URLs so the attributes cannot break", () => { + const html = renderDocsHtml('/a"b', '/c"d'); + expect(html).toContain('data-url="/a"b"'); + expect(html).toContain('src="/c"d"'); + }); +}); diff --git a/packages/plugin-api-docs/src/__tests__/scan.test.ts b/packages/plugin-api-docs/src/__tests__/scan.test.ts new file mode 100644 index 0000000000..069a8503d0 --- /dev/null +++ b/packages/plugin-api-docs/src/__tests__/scan.test.ts @@ -0,0 +1,287 @@ +/** + * Tests for the filesystem mount scanner. + * + * The pure parser and mount-path derivation are exercised with string literals; + * one filesystem integration test builds a temp project tree at runtime and + * scans it — the headline check: the media factory mounted twice must yield TWO + * distinct mounts, distinguished only by the verbs each route file re-exports. + * + * @module __tests__/scan + * @since alpha + */ +import { describe, expect, it } from "vitest"; + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { + classifyRouteSource, + deriveMountPath, + scanAppDirectory, +} from "../scan"; + +describe("classifyRouteSource", () => { + it("classifies the dynamic catch-all (nextly/runtime, arrow-wrapped verbs)", () => { + const code = ` +import { createDynamicHandlers } from "nextly/runtime"; +const h = createDynamicHandlers({ config: cfg }); +export const GET = (req, ctx) => h.GET(req, ctx); +export const POST = (req, ctx) => h.POST(req, ctx); +export const PUT = (req, ctx) => h.PUT(req, ctx); +export const PATCH = (req, ctx) => h.PATCH(req, ctx); +export const DELETE = (req, ctx) => h.DELETE(req, ctx); +export const OPTIONS = (req) => h.OPTIONS(req); +`; + expect(classifyRouteSource(code)).toEqual({ + kind: "nextly", + source: { kind: "dynamic-catchall" }, + verbs: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + }); + }); + + it("classifies the auth'd media mount (full CRUD verbs)", () => { + const code = ` +import { createMediaHandlers } from "nextly/api/media-handlers"; +const h = createMediaHandlers({ config: cfg, requireAuth: true }); +export const GET = h.GET; +export const POST = h.POST; +export const PATCH = h.PATCH; +export const DELETE = h.DELETE; +`; + expect(classifyRouteSource(code)).toEqual({ + kind: "nextly", + source: { kind: "media" }, + verbs: ["GET", "POST", "PATCH", "DELETE"], + }); + }); + + it("classifies the public media mount (GET only) — the double-mount twin", () => { + const code = ` +import { createMediaHandlers } from "nextly/api/media-handlers"; +const h = createMediaHandlers({ config: cfg }); +export const GET = h.GET; +`; + expect(classifyRouteSource(code)).toEqual({ + kind: "nextly", + source: { kind: "media" }, + verbs: ["GET"], + }); + }); + + it("classifies a subpath re-export and ignores a fake export in a JSDoc comment", () => { + // The block comment contains a decoy export with SINGLE quotes and a + // DIFFERENT verb set — identical verbs could not distinguish stripping + // from no-stripping, since both would yield the same result. + const code = ` +/** + * \`\`\`typescript + * export { GET, POST, DELETE } from 'nextly/api/health'; + * \`\`\` + */ +export { GET, HEAD } from "nextly/api/health"; +`; + expect(classifyRouteSource(code)).toEqual({ + kind: "nextly", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET", "HEAD"], + }); + }); + + it("returns non-nextly for a user-owned route with no nextly import", () => { + const code = ` +export async function GET() { + return Response.json({ ok: true }); +} +`; + expect(classifyRouteSource(code)).toEqual({ kind: "non-nextly" }); + }); + + it("flags a nextly reference it cannot classify as unrecognized, with a reason", () => { + const code = ` +import { defineConfig } from "nextly/config"; +export const GET = () => {}; +`; + const result = classifyRouteSource(code); + expect(result.kind).toBe("unrecognized"); + if (result.kind === "unrecognized") { + expect(result.reason).toContain("nextly/config"); + } + }); + + it("flags media-handlers imported without calling the factory", () => { + const code = ` +import { createMediaHandlers } from "nextly/api/media-handlers"; +export const GET = someOtherHandler; +`; + const result = classifyRouteSource(code); + expect(result.kind).toBe("unrecognized"); + if (result.kind === "unrecognized") { + expect(result.reason).toContain("createMediaHandlers"); + } + }); +}); + +describe("deriveMountPath", () => { + it("derives a catch-all mount under src/app", () => { + expect( + deriveMountPath("/proj/src/app/admin/api/[[...params]]/route.ts") + ).toBe("/admin/api/[[...params]]"); + }); + + it("derives a plain mount under app/", () => { + expect(deriveMountPath("/proj/app/api/health/route.ts")).toBe( + "/api/health" + ); + }); + + it("strips route-group segments, which are not part of the URL", () => { + expect(deriveMountPath("/proj/src/app/(frontend)/feed.xml/route.ts")).toBe( + "/feed.xml" + ); + }); + + it("normalizes Windows backslash paths", () => { + expect( + deriveMountPath("C:\\proj\\src\\app\\api\\media\\[[...path]]\\route.ts") + ).toBe("/api/media/[[...path]]"); + }); + + it("throws when the file is not under an app/ directory", () => { + expect(() => deriveMountPath("/proj/server/handlers/route.ts")).toThrow( + /app\// + ); + }); +}); + +describe("scanAppDirectory (filesystem)", () => { + // Builds a temp project mirroring the standard scaffold, scans it, and asserts + // the headline property: one media factory, two distinct mounts. + function buildTempProject(): string { + const root = mkdtempSync(join(tmpdir(), "nextly-docs-scan-")); + const write = (rel: string, content: string): void => { + const full = join(root, "app", rel); + // dirname is OS-separator-aware; a raw lastIndexOf("/") misses on Windows. + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content, "utf8"); + }; + + write("admin/api/[[...params]]/route.ts", `${catchAll}\n`); + write("admin/api/media/[[...path]]/route.ts", `${adminMedia}\n`); + write("api/media/[[...path]]/route.ts", `${publicMedia}\n`); + write("api/health/route.ts", `${health}\n`); + // A user-owned route — must be skipped, not classified. + write("api/custom/route.ts", `${userRoute}\n`); + // A nextly reference in an unknown shape — must surface as unrecognized. + write("api/odd/route.ts", `${oddRoute}\n`); + return root; + } + + it("discovers the media double-mount as two distinct mounts", () => { + const root = buildTempProject(); + try { + const { routes } = scanAppDirectory(root); + const media = routes.filter(r => r.source.kind === "media"); + expect(media).toHaveLength(2); + + const admin = media.find( + r => r.mountPath === "/admin/api/media/[[...path]]" + ); + const pub = media.find(r => r.mountPath === "/api/media/[[...path]]"); + expect(admin?.verbs).toEqual(["GET", "POST", "PATCH", "DELETE"]); + expect(pub?.verbs).toEqual(["GET"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("discovers the catch-all and the subpath re-export", () => { + const root = buildTempProject(); + try { + const { routes } = scanAppDirectory(root); + + const catchAll = routes.find(r => r.source.kind === "dynamic-catchall"); + expect(catchAll?.mountPath).toBe("/admin/api/[[...params]]"); + + const health = routes.find( + r => r.source.kind === "api-subpath" && r.source.subpath === "health" + ); + expect(health).toEqual({ + filePath: expect.stringMatching(/api[\\/]health[\\/]route\.ts$/), + mountPath: "/api/health", + source: { kind: "api-subpath", subpath: "health" }, + verbs: ["GET", "HEAD"], + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("skips user routes and surfaces unrecognized nextly references", () => { + const root = buildTempProject(); + try { + const { routes, unrecognized } = scanAppDirectory(root); + + // Four nextly mounts: catch-all + two media + health. + expect(routes).toHaveLength(4); + expect(routes.some(r => r.mountPath === "/api/custom")).toBe(false); + + expect(unrecognized).toHaveLength(1); + expect(unrecognized[0]?.reason).toContain("nextly/config"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +// ---- shared fixture sources (mirror the real route-file shapes) ------------ + +const catchAll = ` +import { createDynamicHandlers } from "nextly/runtime"; +import cfg from "../../nextly.config"; +const h = createDynamicHandlers({ config: cfg }); +export const GET = (req, ctx) => h.GET(req, ctx); +export const POST = (req, ctx) => h.POST(req, ctx); +export const PUT = (req, ctx) => h.PUT(req, ctx); +export const PATCH = (req, ctx) => h.PATCH(req, ctx); +export const DELETE = (req, ctx) => h.DELETE(req, ctx); +export const OPTIONS = (req) => h.OPTIONS(req); +`; + +const adminMedia = ` +import { createMediaHandlers } from "nextly/api/media-handlers"; +import cfg from "../../../nextly.config"; +const h = createMediaHandlers({ config: cfg, requireAuth: true }); +export const GET = h.GET; +export const POST = h.POST; +export const PATCH = h.PATCH; +export const DELETE = h.DELETE; +`; + +const publicMedia = ` +import { createMediaHandlers } from "nextly/api/media-handlers"; +import cfg from "../../../../nextly.config"; +const h = createMediaHandlers({ config: cfg }); +export const GET = h.GET; +`; + +const health = ` +/** + * Decoy with different verbs + single quotes; the real export below wins. + * \`\`\`typescript + * export { GET, POST, DELETE } from 'nextly/api/health'; + * \`\`\` + */ +export { GET, HEAD } from "nextly/api/health"; +`; + +const userRoute = ` +export async function GET() { + return Response.json({ ok: true }); +} +`; + +const oddRoute = ` +import { defineConfig } from "nextly/config"; +export const GET = () => {}; +`; diff --git a/packages/plugin-api-docs/src/components/envelopes.ts b/packages/plugin-api-docs/src/components/envelopes.ts new file mode 100644 index 0000000000..a5ade82ce5 --- /dev/null +++ b/packages/plugin-api-docs/src/components/envelopes.ts @@ -0,0 +1,169 @@ +/** + * The canonical response envelopes (`api/response-shapes.ts` in core) as + * reusable OpenAPI components, plus the operation-level success-response + * builder. Every mutation answers `{message, item, warnings?}`, every list + * answers `{items, meta}` — one schema each, referenced by every operation, + * so the spec teaches the API's actual shape instead of leaving responses + * undocumented. + * + * @module components/envelopes + * @since alpha + */ +import type { OpenApiSchema } from "./errors"; + +/** Which canonical envelope an operation's success response uses. */ +export type EnvelopeKind = + | "list" // {items, meta} + | "doc" // the bare document + | "mutation" // {message, item, warnings?} + | "action" // {message, ...} + | "data" // a named-field object surface + | "total" // {total} + | "bulk"; // {message, items, errors, warnings?} + +const paginationMeta: OpenApiSchema = { + type: "object", + required: ["total", "page", "limit", "totalPages", "hasNext", "hasPrev"], + properties: { + total: { type: "integer" }, + page: { type: "integer" }, + limit: { type: "integer" }, + totalPages: { type: "integer" }, + hasNext: { type: "boolean" }, + hasPrev: { type: "boolean" }, + }, + additionalProperties: false, +}; + +const warnings: OpenApiSchema = { + type: "array", + items: { type: "string" }, + description: "Non-fatal notices (e.g. side-effect fallbacks).", +}; + +/** `components.schemas` entries every operation shares. */ +export function buildEnvelopeSchemas(): Record { + return { + PaginationMeta: paginationMeta, + ListResponse: { + type: "object", + required: ["items", "meta"], + properties: { + // Item shape is surface-specific (a collection entry, a role, …); the + // envelope itself is the contract being documented here. + items: { type: "array", items: { type: "object" } }, + meta: { $ref: "#/components/schemas/PaginationMeta" }, + }, + additionalProperties: false, + }, + MutationResponse: { + type: "object", + // `item` is required: core's respondMutation(message, item) takes the + // mutated document as a mandatory argument, so every mutation body + // carries it. + required: ["message", "item"], + properties: { + message: { type: "string" }, + item: { type: "object" }, + warnings, + }, + additionalProperties: false, + }, + ActionResponse: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + warnings, + }, + // Action results also carry named fields (id, result, …) which vary per + // operation, so the envelope stays open there. + additionalProperties: true, + }, + DataResponse: { + type: "object", + description: + "A named-field object body; fields vary per operation (see the operation description).", + additionalProperties: true, + }, + TotalResponse: { + type: "object", + required: ["total"], + properties: { total: { type: "integer" } }, + additionalProperties: false, + }, + BulkResponse: { + type: "object", + required: ["message", "items", "errors"], + properties: { + message: { type: "string" }, + items: { type: "array", items: { type: "object" } }, + errors: { + type: "array", + items: { + type: "object", + properties: { id: { type: "string" }, error: { type: "string" } }, + }, + }, + warnings, + }, + additionalProperties: false, + }, + }; +} + +/** A per-operation success response override (schema beyond the envelope). */ +export interface SuccessResponseSpec { + /** HTTP status of the success response (default 200). */ + status?: number; + /** Replaces the envelope $ref with a surface-specific schema. */ + schema?: OpenApiSchema; + description?: string; +} + +/** + * Build the success `responses` entry for an operation from its envelope kind. + * Falls open (no entry) for unknown kinds — errors are always attached by the + * generator separately. + */ +export function successResponse( + envelope: EnvelopeKind | undefined, + spec?: SuccessResponseSpec +): Record | undefined { + if (!envelope) return undefined; + const refFor: Record = { + list: "#/components/schemas/ListResponse", + doc: "#/components/schemas/DataResponse", + mutation: "#/components/schemas/MutationResponse", + action: "#/components/schemas/ActionResponse", + data: "#/components/schemas/DataResponse", + total: "#/components/schemas/TotalResponse", + bulk: "#/components/schemas/BulkResponse", + }; + const schema = spec?.schema ?? { $ref: refFor[envelope] }; + const status = String(spec?.status ?? 200); + return { + [status]: { + description: + spec?.description ?? + (envelope === "doc" ? "The document." : "Success."), + content: { "application/json": { schema } }, + }, + }; +} + +/** A generic JSON request body for write operations without a known schema. */ +export function genericJsonRequest(): Record { + return { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: true, + description: "Request body; shape is surface-specific.", + }, + }, + }, + }; +} diff --git a/packages/plugin-api-docs/src/components/errors.ts b/packages/plugin-api-docs/src/components/errors.ts new file mode 100644 index 0000000000..e3f217c952 --- /dev/null +++ b/packages/plugin-api-docs/src/components/errors.ts @@ -0,0 +1,104 @@ +/** + * OpenAPI error schemas generated from the live error-code enum. + * + * Every error code and status mapping is derived from `NEXTLY_ERROR_STATUS` + * (the canonical core enum, via the plugin-sdk) by iteration — never + * hand-listed — so a new core code appears in the spec with no edit here, and a + * dropped code is caught by a test asserting the full enum is represented. + * + * @module components/errors + * @since alpha + */ +import { + NEXTLY_ERROR_STATUS, + type NextlyErrorCode, +} from "@nextlyhq/plugin-sdk"; + +/** A loose JSON object — the shape OpenAPI schemas take when serialized. */ +export type OpenApiSchema = Record; + +/** One OpenAPI response object, keyed by HTTP status string at use sites. */ +export interface OpenApiResponse { + description: string; + /** Response-level headers (`x-request-id` always, `retry-after` on 429). */ + headers?: Record; + content?: Record; +} + +/** The generated error component + per-status responses. */ +export interface ErrorComponents { + /** `components.schemas.ErrorResponse` — the canonical `{ error: {...} }` body. */ + errorResponseSchema: OpenApiSchema; + /** `components.responses.` — one entry per status the enum defines. */ + responsesByStatus: Record; +} + +/** + * Build the error schemas and status-grouped responses from the live enum. + */ +export function buildErrorComponents(): ErrorComponents { + const codes = Object.keys(NEXTLY_ERROR_STATUS) as NextlyErrorCode[]; + + // Group codes by HTTP status so each response entry carries exactly the codes + // that status can produce. + const codesByStatus = new Map(); + for (const code of codes) { + const status = NEXTLY_ERROR_STATUS[code]; + const bucket = codesByStatus.get(status); + if (bucket) bucket.push(code); + else codesByStatus.set(status, [code]); + } + + const errorResponseSchema: OpenApiSchema = { + type: "object", + required: ["error"], + properties: { + error: { + type: "object", + required: ["code", "message", "requestId"], + properties: { + // The full live enum — the single property that MUST stay generated. + code: { enum: codes }, + message: { type: "string" }, + requestId: { type: "string" }, + data: { type: "object", additionalProperties: true }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }; + + const responsesByStatus: Record = {}; + for (const [status, statusCodes] of codesByStatus) { + // Every response carries `x-request-id` (correlation, always emitted); 429 + // additionally documents `retry-after`. + const headers: Record< + string, + { schema: OpenApiSchema; description: string } + > = { + "x-request-id": { + schema: { type: "string" }, + description: + "Correlation id for this request (also in the error body).", + }, + }; + if (status === 429) { + headers["retry-after"] = { + schema: { type: "integer" }, + description: "Seconds to wait before retrying (RATE_LIMITED).", + }; + } + responsesByStatus[String(status)] = { + description: `Error response (${statusCodes.join(", ")}).`, + headers, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }; + } + + return { errorResponseSchema, responsesByStatus }; +} diff --git a/packages/plugin-api-docs/src/components/security.ts b/packages/plugin-api-docs/src/components/security.ts new file mode 100644 index 0000000000..74104d7c3c --- /dev/null +++ b/packages/plugin-api-docs/src/components/security.ts @@ -0,0 +1,42 @@ +/** + * OpenAPI security schemes and the auth → operation-security mapping. + * + * Nextly authenticates two ways — a session cookie (`nextly_session`) and a + * Bearer API key — and an operation is reachable by EITHER, so a non-public + * operation lists both as alternatives (OpenAPI OR semantics across array + * entries). The finer RBAC permission an operation carries is a runtime check + * surfaced via the operation's `x-nextly-permission` extension rather than the + * security model, because OpenAPI security schemes express identity, not + * authorization. + * + * @module components/security + * @since alpha + */ +import type { DocsAuthMode } from "../descriptors"; + +/** `components.securitySchemes`. */ +export const SECURITY_SCHEMES = { + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "nextly_session", + description: "Admin session cookie issued at login.", + }, + bearerAuth: { + type: "http", + scheme: "bearer", + description: "API key sent as `Authorization: Bearer `.", + }, +} as const; + +/** OpenAPI security requirement: scheme name → empty scopes array. */ +export type SecurityRequirement = Record; + +/** + * Map an auth mode to an OpenAPI operation `security` value. `public` → `[]` + * (explicitly no security); otherwise cookie OR bearer. + */ +export function securityFor(auth: DocsAuthMode): SecurityRequirement[] { + if (auth === "public") return []; + return [{ cookieAuth: [] }, { bearerAuth: [] }]; +} diff --git a/packages/plugin-api-docs/src/descriptors.ts b/packages/plugin-api-docs/src/descriptors.ts new file mode 100644 index 0000000000..f86a98d199 --- /dev/null +++ b/packages/plugin-api-docs/src/descriptors.ts @@ -0,0 +1,68 @@ +/** + * The plugin's internal operation model. + * + * One shape both operation sources map onto: the core admin-REST introspection + * seam (`listAdminRestOperations`, imported from the plugin-sdk) and the + * plugin-route view (`listPluginRoutes`). Keeping a single model means the + * generator has one input type regardless of where an operation came from. + * + * @module descriptors + * @since alpha + */ +import type { AdminRestOperation } from "@nextlyhq/plugin-sdk"; + +/** Verbs an OpenAPI operation may carry (route handlers can also export HEAD/OPTIONS). */ +export type DocsVerb = + "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + +/** How an operation is secured (mirrors the seam's `RestAuthMode`). */ +export type DocsAuthMode = "public" | "authenticated" | "permission"; + +/** The canonical response envelope (mirrors the seam's `RestEnvelope`). */ +export type DocsEnvelope = + "list" | "doc" | "mutation" | "action" | "data" | "total" | "bulk"; + +/** A single documented operation. */ +export interface DocsOperation { + /** Service name ("users", "plugins", ...). */ + service: string; + /** Operation identity — the operationId basis. */ + operation: string; + /** HTTP verb. */ + method: DocsVerb; + /** Path relative to the mount root, leading "/", OpenAPI `{param}` style. */ + path: string; + /** How the operation is secured. */ + auth: DocsAuthMode; + /** Required when `auth === "permission"`; an RBAC slug. */ + permissionSlug?: string; + /** Grouping tag. */ + tag: string; + /** The canonical response envelope kind. */ + envelope?: DocsEnvelope; + /** Optional summary (e.g. from a plugin route's `openapi?` annotation). */ + summary?: string; + /** Optional longer description. */ + description?: string; + /** Optional extra grouping tags, merged with `tag` in the operation. */ + tags?: readonly string[]; + /** Surface-specific success schema (overrides the envelope $ref). */ + responseSchema?: Record; + /** Surface-specific request-body schema for write operations. */ + requestSchema?: Record; + /** Upload bodies are multipart/form-data (file part + metadata), not JSON. */ + requestMultipart?: boolean; + /** Success status override (e.g. 201 for creates). */ + successStatus?: number; +} + +/** + * Adopt the core seam's admin REST operations as DocsOperations. The shapes are + * structurally identical; the copy decouples the plugin's model from the seam's + * so either can evolve without dragging the other. + */ +export function restOperationsToDocs( + ops: readonly AdminRestOperation[] +): DocsOperation[] { + return ops.map(op => ({ ...op })); +} diff --git a/packages/plugin-api-docs/src/excludes.ts b/packages/plugin-api-docs/src/excludes.ts new file mode 100644 index 0000000000..df73bb97e9 --- /dev/null +++ b/packages/plugin-api-docs/src/excludes.ts @@ -0,0 +1,92 @@ +/** + * Spec redaction options (the plugin's typed excludes). + * + * The three arrays are typed separately (not one mixed array) so a path glob + * cannot be mistaken for a service name or an error code. Service excludes are + * applied by filtering the operation lists BEFORE generation; path/code excludes + * are applied after, on the assembled document. + * + * @module excludes + * @since alpha + */ +import type { DocsOperation } from "./descriptors"; +import type { OpenApiDocument } from "./generate"; + +/** The plugin's exclude options. */ +export interface ExcludeOptions { + /** Glob patterns matched against full path entries to drop from the spec. */ + excludePaths?: readonly string[]; + /** Service names whose operations are dropped from the spec. */ + excludeServices?: readonly string[]; + /** Error codes dropped from the generated error component. */ + excludeErrorCodes?: readonly string[]; +} + +/** + * Convert a glob (supports `*` for a path segment and `**` across segments) to a + * RegExp. Kept tiny and dependency-free; excludes are an operator convenience. + */ +function globToRegExp(glob: string): RegExp { + let re = ""; + for (let i = 0; i < glob.length; i += 1) { + const c = glob[i]; + if (c === "*" && glob[i + 1] === "*") { + re += ".*"; + i += 1; + } else if (c === "*") { + re += "[^/]*"; + } else if (/[^A-Za-z0-9_/-]/.test(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +/** Drop operations whose service is named in `excludeServices`. */ +export function excludeOperationsByService( + operations: readonly DocsOperation[], + excludeServices: readonly string[] | undefined +): DocsOperation[] { + if (!excludeServices || excludeServices.length === 0) return [...operations]; + const drop = new Set(excludeServices); + return operations.filter(op => !drop.has(op.service)); +} + +/** + * Drop paths and error codes from an assembled document. The document is + * freshly generated on every call, so the error-code branch mutates nested + * schema objects in place via explicit Record casts. + */ +export function applyExcludes( + doc: OpenApiDocument, + options: ExcludeOptions +): OpenApiDocument { + if (options.excludePaths && options.excludePaths.length > 0) { + const pathRegexes = options.excludePaths.map(globToRegExp); + const paths = (doc.paths ?? {}) as Record; + doc.paths = Object.fromEntries( + Object.entries(paths).filter(([p]) => !pathRegexes.some(re => re.test(p))) + ); + } + + if (options.excludeErrorCodes && options.excludeErrorCodes.length > 0) { + const dropped = new Set(options.excludeErrorCodes); + const components = (doc.components ?? {}) as Record; + const schemas = components.schemas as Record; + const errorResponse = schemas.ErrorResponse as Record; + const errorResponseProps = errorResponse.properties as Record< + string, + unknown + >; + const error = errorResponseProps.error as Record; + const errorProps = error.properties as Record; + const codeProp = errorProps.code as Record; + codeProp.enum = ((codeProp.enum as string[]) ?? []).filter( + c => !dropped.has(c) + ); + } + + return doc; +} diff --git a/packages/plugin-api-docs/src/fields.ts b/packages/plugin-api-docs/src/fields.ts new file mode 100644 index 0000000000..242a1efc33 --- /dev/null +++ b/packages/plugin-api-docs/src/fields.ts @@ -0,0 +1,205 @@ +/** + * `fields[]` → OpenAPI schema emitter (the plan's "path b"). + * + * The user's config `fields[]` is the single source of truth for what a + * collection entry or single document looks like; this emitter turns it into + * OpenAPI schemas at request time, so the docs describe YOUR collections — + * add a field to `Posts` and the spec grows it on the next request. No Zod + * round-trip, no second source of truth to drift. + * + * A minimal field view is accepted (name + type + the few options that shape + * the wire format) so core's full field-config types stay out of the plugin. + * + * @module fields + * @since alpha + */ + +/** The minimal field shape the emitter reads from a config. */ +export interface FieldLike { + name: string; + type: string; + required?: boolean; + localized?: boolean; + hasMany?: boolean; + options?: Array<{ label?: string; value: string | number }>; + fields?: FieldLike[]; // repeater/group containers + component?: string; + components?: string[]; + defaultValue?: unknown; +} + +/** A collection/single surface as read from the host config. */ +export interface ContentSurfaceLike { + slug: string; + labels?: { singular?: string; plural?: string }; + fields: FieldLike[]; +} + +export interface OpenApiSchema { + [k: string]: unknown; +} + +/** Component schemas collected while emitting, keyed by component slug. */ +export interface ComponentSchemas { + schemas: Record; + /** Field names referencing each component (for descriptions). */ + refs: Map; +} + +/** Options that relax required fields for PATCH (update) bodies. */ +export interface EmitOptions { + /** PATCH semantics: every field optional. */ + allOptional?: boolean; +} + +function scalarSchema(field: FieldLike): OpenApiSchema { + switch (field.type) { + case "text": + case "textarea": + case "richText": + case "code": + case "password": + return { type: "string" }; + case "email": + return { type: "string", format: "email" }; + case "number": + return { type: "number" }; + case "checkbox": + return { type: "boolean" }; + case "date": + return { type: "string", format: "date-time" }; + case "select": + case "radio": { + const values = (field.options ?? []).map(o => o.value); + return values.length > 0 + ? { + type: typeof values[0] === "number" ? "integer" : "string", + enum: values, + } + : { type: "string" }; + } + case "chips": + return { type: "array", items: { type: "string" } }; + case "json": + return { type: "object", additionalProperties: true }; + // relationship + upload carry the target's id (expanded reads are a + // query-time projection, not the stored shape); hasMany stores an ARRAY of + // those ids — the stored wire shape, which is what a schema documents. + case "relationship": + case "upload": { + const id = { type: "string" }; + return field.hasMany ? { type: "array", items: id } : id; + } + // Plugin-contributed types: unknown wire shape, open object. + default: + return { + type: "object", + additionalProperties: true, + description: `Plugin field type "${field.type}".`, + }; + } +} + +/** + * Emit the properties+required pair for one field list. Container types + * (repeater/group) recurse; component fields collect into `components` for the + * caller to register as named schemas. + */ +function emitProperties( + fields: readonly FieldLike[], + components: ComponentSchemas, + opts: EmitOptions +): { properties: Record; required: string[] } { + const properties: Record = {}; + const required: string[] = []; + for (const field of fields) { + let schema: OpenApiSchema; + if (field.type === "repeater" || field.type === "group") { + const inner = emitProperties(field.fields ?? [], components, opts); + schema = { + type: "object", + properties: inner.properties, + ...(inner.required.length > 0 ? { required: inner.required } : {}), + additionalProperties: false, + }; + if (field.type === "repeater") { + schema = { type: "array", items: schema }; + } + } else if ( + field.type === "component" && + (field.component ?? field.components?.[0]) + ) { + const slug = (field.component ?? field.components?.[0]) as string; + schema = { $ref: `#/components/schemas/Component_${slug}` }; + const names = components.refs.get(slug) ?? []; + names.push(field.name); + components.refs.set(slug, names); + components.schemas[`Component_${slug}`] ??= { + type: "object", + additionalProperties: true, + description: `Fields of the "${slug}" field group (schema defined in the app config).`, + }; + } else { + schema = scalarSchema(field); + } + if (field.localized) { + schema = { + oneOf: [ + schema, + { + type: "object", + additionalProperties: schema, + description: "Localized values keyed by locale.", + }, + ], + }; + } + properties[field.name] = schema; + if (field.required && !opts.allOptional) required.push(field.name); + } + return { properties, required }; +} + +/** Emit a full object schema for one field list. */ +export function fieldsToSchema( + fields: readonly FieldLike[], + components: ComponentSchemas, + opts: EmitOptions = {} +): OpenApiSchema { + const { properties, required } = emitProperties(fields, components, opts); + return { + type: "object", + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + }; +} + +/** Wrap a document schema with the system columns every entry carries. */ +export function entrySchema( + fields: readonly FieldLike[], + components: ComponentSchemas, + opts: EmitOptions = {} +): OpenApiSchema { + const base = fieldsToSchema(fields, components, opts) as { + properties: Record; + required?: string[]; + }; + return { + type: "object", + properties: { + id: { type: "string", description: "Entry id." }, + ...base.properties, + status: { + type: "string", + enum: ["draft", "published"], + description: "Publishing status (collections with drafts).", + }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + ...(base.required + ? { required: ["id", ...base.required] } + : { required: ["id"] }), + }; +} diff --git a/packages/plugin-api-docs/src/generate.ts b/packages/plugin-api-docs/src/generate.ts new file mode 100644 index 0000000000..8974cf0bb7 --- /dev/null +++ b/packages/plugin-api-docs/src/generate.ts @@ -0,0 +1,293 @@ +/** + * OpenAPI document assembly. + * + * Combines the operation sources into a complete OpenAPI 3.1 document: the + * admin REST operations (core seam via the sdk), the plugin-route operations + * (sdk view), and the filesystem scan (where the app mounts things). Error and + * envelope components are generated from the live enum / the canonical response + * shapes; security schemes reflect the cookie + bearer auth model. + * + * When the host config's content surfaces are supplied (`content`), the + * templated collection/single entry operations EXPAND into concrete per-slug + * paths whose request/response schemas are emitted from the user's `fields[]` — + * dynamic docs: add a collection or field, and the next request documents it. + * + * @module generate + * @since alpha + */ +import { + listHealthSurfaceOperations, + listMediaSurfaceOperations, + type AdminRestOperation, +} from "@nextlyhq/plugin-sdk"; + +import { buildEnvelopeSchemas } from "./components/envelopes"; +import { buildErrorComponents } from "./components/errors"; +import { SECURITY_SCHEMES } from "./components/security"; +import type { DocsOperation } from "./descriptors"; +import { restOperationsToDocs } from "./descriptors"; +import type { ComponentSchemas, ContentSurfaceLike } from "./fields"; +import { entrySchema, fieldsToSchema } from "./fields"; +import { buildPaths, mountBasePath, type OpenApiPaths } from "./paths"; +import type { ScanResult } from "./scan"; + +/** Host-config content surfaces for dynamic expansion. */ +export interface ContentConfig { + collections?: readonly ContentSurfaceLike[]; + singles?: readonly ContentSurfaceLike[]; + fieldGroups?: readonly ContentSurfaceLike[]; +} + +/** Input to the generator. */ +export interface OpenApiInput { + /** The filesystem scan result (mounts + verbs). */ + scan: ScanResult; + /** Admin REST operations (from the core seam). */ + restOperations?: readonly DocsOperation[]; + /** Plugin-route operations (derived from the sdk's plugin-route view). */ + pluginOperations?: readonly DocsOperation[]; + /** The app's content surfaces; enables dynamic per-slug expansion. */ + content?: ContentConfig; + /** + * Anonymous-viewer mode: emit ONLY public operations (auth === "public") and + * skip content expansion entirely — collection/single shapes describe gated + * surfaces. Used when a published spec is read by an unauthenticated caller. + */ + publicOnly?: boolean; + /** `info.title` / `info.version` overrides. */ + info?: { title?: string; version?: string }; + /** Optional `servers[0].url`. */ + serverUrl?: string; +} + +/** The generated OpenAPI document (loosely typed JSON). */ +export type OpenApiDocument = Record; + +/** Default base when the scan found no catch-all mount (e.g. a public-only app). */ +const DEFAULT_CATCHALL_BASE = "/admin/api"; + +/** Collection/single entry operations that expand per slug. */ +const TEMPLATED_ENTRY_PREFIXES = [ + "/collections/{collectionName}", + "/singles/{slug}", +]; + +function isTemplatedContentOp(op: DocsOperation): boolean { + return TEMPLATED_ENTRY_PREFIXES.some(p => op.path.startsWith(p)); +} + +/** + * Expand the templated collection/single operations into concrete per-slug + * operations with fields-derived schemas. Definition-level ops (list/create/ + * schema preview) keep their templated form; per-slug entry ops replace the + * template so each surface appears once with real schemas. + */ +function expandContentOperations( + ops: readonly DocsOperation[], + content: ContentConfig | undefined, + components: ComponentSchemas +): DocsOperation[] { + if (!content) return [...ops]; + + // Register component (field-group) schemas first so $refs resolve. + for (const group of content.fieldGroups ?? []) { + components.schemas[`Component_${group.slug}`] ??= { + ...fieldsToSchema(group.fields ?? [], components), + description: `Fields of the "${group.slug}" field group.`, + }; + } + + const out: DocsOperation[] = []; + for (const op of ops) { + if (!isTemplatedContentOp(op)) { + out.push(op); + continue; + } + const isCollection = op.path.startsWith("/collections/{collectionName}"); + const surfaces = isCollection + ? (content.collections ?? []) + : (content.singles ?? []); + // No surfaces of this kind exist (e.g. an app with singles but zero + // collections): keep the TEMPLATED operation rather than dropping it, so a + // sparse registry cannot silently delete half the documented surface. + if (surfaces.length === 0) { + out.push(op); + continue; + } + for (const surface of surfaces) { + const param = isCollection ? "{collectionName}" : "{slug}"; + const label = + surface.labels?.plural ?? surface.labels?.singular ?? surface.slug; + const schema = entrySchema(surface.fields ?? [], components); + // POST bodies enforce required fields; PATCH bodies are all-optional. + const postSchema = fieldsToSchema(surface.fields ?? [], components); + const patchSchema = fieldsToSchema(surface.fields ?? [], components, { + allOptional: true, + }); + out.push({ + ...op, + operation: `${op.operation}.${surface.slug}`, + path: op.path.replaceAll(param, surface.slug), + permissionSlug: op.permissionSlug?.replaceAll( + isCollection ? "{collectionName}" : "{slug}", + surface.slug + ), + tag: label, + // Reads answer the document; writes answer the mutation envelope with + // the document inside; write bodies are the fields themselves. + responseSchema: + op.envelope === "doc" + ? schema + : op.envelope === "list" + ? { + type: "object", + required: ["items", "meta"], + properties: { + items: { type: "array", items: schema }, + meta: { $ref: "#/components/schemas/PaginationMeta" }, + }, + } + : op.envelope === "mutation" + ? { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + item: schema, + warnings: { type: "array", items: { type: "string" } }, + }, + } + : undefined, + requestSchema: + op.method === "POST" + ? postSchema + : op.method === "PATCH" + ? patchSchema + : undefined, + }); + } + } + return out; +} + +/** + * Generate a complete OpenAPI 3.1 document. Pure: takes the scan + operation + * lists + content surfaces as input, so it is fully testable without booting. + */ +export function generateOpenApiDocument(input: OpenApiInput): OpenApiDocument { + const components = { schemas: {}, refs: new Map() }; + const restOperations = + input.restOperations ?? restOperationsToDocs([] as AdminRestOperation[]); + const pluginOperations = input.pluginOperations ?? []; + const { errorResponseSchema, responsesByStatus } = buildErrorComponents(); + + // Anonymous-viewer mode keeps only public operations and skips content + // expansion: gated operations (and the collection/single field shapes, which + // describe gated surfaces) are not an anonymous reader's business. + const visibilityFilter = (ops: readonly DocsOperation[]): DocsOperation[] => + input.publicOnly ? ops.filter(op => op.auth === "public") : [...ops]; + + // Dynamic expansion replaces templated per-slug entry ops with concrete ones. + const expanded = expandContentOperations( + visibilityFilter([...restOperations, ...pluginOperations]), + input.publicOnly ? undefined : input.content, + components + ); + + // Standalone mounted surfaces FIRST: the public-facing surface (health, + // public media reads) is what an API consumer reaches for first, so it leads + // the document and the docs sidebar instead of hiding under 30 admin groups. + // The media factory is mounted twice — auth'd CRUD + public reads — and the + // health check; ops attach at each mount's base, filtered by the verbs the + // route file re-exports (that is exactly what distinguishes the two media + // mounts), with auth resolved from the mount. + let paths: OpenApiPaths = {}; + for (const mount of input.scan.routes) { + if (mount.source.kind === "media") { + const isAdminMount = mount.mountPath.startsWith("/admin"); + const base = mountBasePath(mount.mountPath); + // Mount qualifier keeps operationIds unique: both mounts emit listMedia, + // and OpenAPI requires every operationId to be unique or client + // generators overwrite one with the other. + const qualifier = isAdminMount ? "admin" : "public"; + const ops = listMediaSurfaceOperations() + // A mount only serves the verbs its route file re-exports, AND a + // non-admin mount never serves a write descriptor even if its scan + // reported the verb — a public mount must not document uploads. + .filter( + op => mount.verbs.includes(op.method) && (isAdminMount || !op.write) + ) + .map((op): DocsOperation => ({ + service: "media", + operation: `${op.operation}.${qualifier}`, + method: op.method, + path: op.path, + auth: isAdminMount ? "permission" : "public", + permissionSlug: isAdminMount ? op.adminPermission : undefined, + tag: isAdminMount ? "Media" : "Media (Public)", + envelope: op.envelope, + summary: op.summary, + ...(op.multipart ? { requestMultipart: true } : {}), + })); + paths = { + ...paths, + ...buildPaths(base, visibilityFilter(ops), responsesByStatus), + }; + } else if ( + mount.source.kind === "api-subpath" && + mount.source.subpath === "health" + ) { + const base = mountBasePath(mount.mountPath); + const ops = listHealthSurfaceOperations() + .filter(op => mount.verbs.includes(op.method)) + .map((op): DocsOperation => ({ + service: "health", + operation: op.operation, + method: op.method, + path: op.path, + auth: "public", + tag: "Health", + envelope: op.envelope, + summary: op.summary, + })); + paths = { + ...paths, + ...buildPaths(base, visibilityFilter(ops), responsesByStatus), + }; + } + } + + const catchAllMounts = input.scan.routes.filter( + r => r.source.kind === "dynamic-catchall" + ); + const bases = + catchAllMounts.length > 0 + ? catchAllMounts.map(m => mountBasePath(m.mountPath)) + : [DEFAULT_CATCHALL_BASE]; + + for (const base of bases) { + paths = { + ...paths, + ...buildPaths(base, expanded, responsesByStatus), + }; + } + + return { + openapi: "3.1.0", + info: { + title: input.info?.title ?? "Nextly API", + version: input.info?.version ?? "0.0.0", + }, + ...(input.serverUrl ? { servers: [{ url: input.serverUrl }] } : {}), + paths, + components: { + schemas: { + ErrorResponse: errorResponseSchema, + ...buildEnvelopeSchemas(), + ...components.schemas, + }, + responses: responsesByStatus, + securitySchemes: SECURITY_SCHEMES, + }, + }; +} diff --git a/packages/plugin-api-docs/src/index.ts b/packages/plugin-api-docs/src/index.ts new file mode 100644 index 0000000000..33fd090380 --- /dev/null +++ b/packages/plugin-api-docs/src/index.ts @@ -0,0 +1,30 @@ +/** + * `@nextlyhq/plugin-api-docs` public entry. + * + * @module index + */ +export { apiDocsPlugin, renderDocsHtml } from "./plugin"; +export type { ApiDocsPluginOptions } from "./plugin"; +export { + generateOpenApiDocument, + type OpenApiInput, + type OpenApiDocument, +} from "./generate"; +export { + scanAppDirectory, + classifyRouteSource, + deriveMountPath, + type ScanResult, + type ScannedRoute, + type RouteSource, + type RouteVerb, +} from "./scan"; +export { pluginRoutesToDocs } from "./plugin-routes"; +export { restOperationsToDocs } from "./descriptors"; +export { applyMountOverrides, type MountOverride } from "./mount-overrides"; +export { + applyExcludes, + excludeOperationsByService, + type ExcludeOptions, +} from "./excludes"; +export type { DocsOperation, DocsVerb, DocsAuthMode } from "./descriptors"; diff --git a/packages/plugin-api-docs/src/layering.test.ts b/packages/plugin-api-docs/src/layering.test.ts new file mode 100644 index 0000000000..d3291286af --- /dev/null +++ b/packages/plugin-api-docs/src/layering.test.ts @@ -0,0 +1,60 @@ +/** + * Layering contract for the API Docs plugin. + * + * The plugin reaches core ONLY through `@nextlyhq/plugin-sdk` (the stable + * surface), is framework-agnostic (zero `next`/`react`), and never imports + * `@nextlyhq/admin` directly. The guard REFUSES a blocklist of known-banned + * specifier shapes — it rejects every banned package and subpath outright, at + * the cost of not proving the absence of an unlisted dependency (a true import + * allowlist would). Comments are stripped before matching so a JSDoc example + * import is not a false positive. Scans `src/` recursively. + * + * @module layering + * @since alpha + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const SRC_DIR = dirname(fileURLToPath(import.meta.url)); + +// `next`, `next/*`, `react`, `react-dom`, a direct `@nextlyhq/admin` reach, and +// a direct `nextly` import are all refused — the sdk is the only core surface. +// (`nextly` as a substring does not match: the regex requires the closing quote.) +const FORBIDDEN = + /\bfrom\s+["'](?:next(?:\/[^"']*)?|react|react-dom|@nextlyhq\/admin(?:\/[^"']*)?|nextly(?:\/[^"']*)?)["']/; + +function listSourceFiles(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name === "dist" || name.startsWith(".")) { + continue; + } + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) { + listSourceFiles(full, out); + } else if (name.endsWith(".ts") && !name.endsWith(".test.ts")) { + out.push(full); + } + } + return out; +} + +describe("layering", () => { + it("imports core only through @nextlyhq/plugin-sdk (no next/react/admin/nextly)", () => { + const sourceFiles = listSourceFiles(SRC_DIR); + expect(sourceFiles.length).toBeGreaterThan(0); + const offenders: string[] = []; + for (const file of sourceFiles) { + const code = readFileSync(file, "utf8"); + // Drop comments so an example inside JSDoc cannot trip the guard. + const stripped = code + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); + if (FORBIDDEN.test(stripped)) offenders.push(file); + } + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/plugin-api-docs/src/mount-overrides.ts b/packages/plugin-api-docs/src/mount-overrides.ts new file mode 100644 index 0000000000..49692c1616 --- /dev/null +++ b/packages/plugin-api-docs/src/mount-overrides.ts @@ -0,0 +1,70 @@ +/** + * Mount overrides for OpenAPI generation. + * + * The filesystem scan is the default source of truth for where the API is + * mounted, but it cannot express every layout: a non-standard factory wrapper, + * a route file the parser does not recognize, or a mount outside the app + * router. The plugin's `mounts` option is the explicit escape hatch — declare a + * mount, and {@link applyMountOverrides} merges it with the scan: an override + * whose `mountPath` matches a scanned route REPLACES it; one that matches + * nothing is ADDED. Scanned routes not mentioned are kept as-is. + * + * @module mount-overrides + * @since alpha + */ +import type { RouteSource, RouteVerb, ScannedRoute, ScanResult } from "./scan"; + +/** An explicitly declared mount, overriding or supplementing the scan. */ +export interface MountOverride { + /** Mount path relative to the app-router root (matches {@link ScannedRoute.mountPath}). */ + mountPath: string; + /** The surface this mount exposes. */ + source: RouteSource; + /** HTTP verbs this mount serves (canonical order). */ + verbs: RouteVerb[]; +} + +/** + * Merge explicit mount overrides into a scan result. Replace semantics are by + * `mountPath`; last-write-wins on duplicate override paths keeps the merge + * deterministic. The `unrecognized` list is passed through — an override does + * not silence a scan warning. + */ +export function applyMountOverrides( + scan: ScanResult, + overrides?: readonly MountOverride[] +): ScanResult { + if (!overrides || overrides.length === 0) return scan; + + const overrideByPath = new Map(); + for (const ov of overrides) overrideByPath.set(ov.mountPath, ov); + + const routes: ScannedRoute[] = []; + const consumed = new Set(); + for (const route of scan.routes) { + const ov = overrideByPath.get(route.mountPath); + if (ov) { + // Preserve the original filePath when correcting a scanned mount, so the + // spec still points at the real file for operators. + routes.push({ + filePath: route.filePath, + mountPath: ov.mountPath, + source: ov.source, + verbs: ov.verbs, + }); + consumed.add(route.mountPath); + } else { + routes.push(route); + } + } + // Overrides for paths the scan never found declare a brand-new mount. + for (const ov of overrides) { + if (consumed.has(ov.mountPath)) continue; + routes.push({ + mountPath: ov.mountPath, + source: ov.source, + verbs: ov.verbs, + }); + } + return { routes, unrecognized: scan.unrecognized }; +} diff --git a/packages/plugin-api-docs/src/paths.ts b/packages/plugin-api-docs/src/paths.ts new file mode 100644 index 0000000000..7e827642d3 --- /dev/null +++ b/packages/plugin-api-docs/src/paths.ts @@ -0,0 +1,143 @@ +/** + * Turn documented operations + scanned mounts into OpenAPI `paths`. + * + * An operation carries a path RELATIVE to its mount root; the generator joins it + * with the mount's base (catch-all segment stripped) to produce the full OpenAPI + * path. Each operation lands under its lowercased HTTP verb, tagged and secured + * per its auth mode, with the generated error responses attached. + * + * @module paths + * @since alpha + */ +import { genericJsonRequest, successResponse } from "./components/envelopes"; +import type { OpenApiResponse } from "./components/errors"; +import { securityFor } from "./components/security"; +import type { DocsOperation } from "./descriptors"; + +export type OpenApiOperation = Record; +export type OpenApiPaths = Record>; + +/** + * Strip a trailing dynamic/catch-all segment to get the static mount base. + * `/admin/api/[[...params]]` → `/admin/api`; a static mount like `/api/health` + * is returned unchanged. + */ +export function mountBasePath(mountPath: string): string { + const segs = mountPath.split("/"); + const last = segs[segs.length - 1]; + if (last.startsWith("[")) segs.pop(); + return segs.join("/") || "/"; +} + +/** Join a mount base with a relative operation path (one leading "/" kept). */ +export function joinPath(base: string, relative: string): string { + const left = base.endsWith("/") ? base.slice(0, -1) : base; + // A mount-root operation ("/") IS the base — OpenAPI paths carry no trailing + // slash, and appending one would create a key nothing looks up. + if (relative === "/" || relative === "") return left || "/"; + const right = relative.startsWith("/") ? relative : `/${relative}`; + return `${left}${right}`; +} + +/** + * Build one OpenAPI operation. The RBAC slug rides along as a vendor extension + * (`x-nextly-permission`) so consumers can see the requirement the security + * model cannot express. Success responses come from the operation's envelope + * (or its surface-specific schema), and write verbs carry a request body. + */ +export function buildOperation( + op: DocsOperation, + errorResponses: Record +): OpenApiOperation { + const success = successResponse(op.envelope, { + status: op.successStatus, + schema: op.responseSchema, + description: op.responseSchema ? "The document." : undefined, + }); + // Error responses are SHARED: every operation references the one + // components.responses entry per status instead of inlining twelve full + // objects — the document stays small and the raw JSON readable, while + // renderers resolve the identical content through the $ref. + const errorRefs = Object.fromEntries( + Object.keys(errorResponses).map(status => [ + status, + { $ref: `#/components/responses/${status}` }, + ]) + ); + const responses = { ...success, ...errorRefs }; + const operation: OpenApiOperation = { + operationId: op.operation, + tags: [op.tag], + security: securityFor(op.auth), + responses, + }; + // OpenAPI requires every `{name}` in the path to be declared as a required + // path parameter, or tooling rejects the document / generates incomplete + // clients. Derived from the operation's own path so the two cannot disagree. + const pathParams = [...op.path.matchAll(/\{([A-Za-z_][\w]*)\}/g)].map( + m => m[1] + ); + if (pathParams.length > 0) { + operation.parameters = pathParams.map(name => ({ + name, + in: "path", + required: true, + schema: { type: "string" }, + })); + } + if (op.method === "POST" || op.method === "PATCH" || op.method === "PUT") { + operation.requestBody = op.requestMultipart + ? { + required: true, + content: { + // File upload: a binary file part plus JSON metadata fields. + "multipart/form-data": { + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + }, + additionalProperties: true, + }, + }, + }, + } + : op.requestSchema + ? { + required: true, + content: { "application/json": { schema: op.requestSchema } }, + } + : genericJsonRequest(); + } + // Extra tags (a plugin's openapi.tags) merge with the primary tag. + if (op.tags && op.tags.length > 0) { + operation.tags = [op.tag, ...op.tags]; + } + // Optional annotation — omitted entirely when absent. + if (op.summary) operation.summary = op.summary; + if (op.description) operation.description = op.description; + if (op.auth === "permission" && op.permissionSlug) { + operation["x-nextly-permission"] = op.permissionSlug; + } + return operation; +} + +/** + * Fold a list of operations into a `paths` map under a shared mount base. Two + * operations that resolve to the same full path merge their verbs into one path + * item (the normal REST shape). + */ +export function buildPaths( + mountBase: string, + operations: readonly DocsOperation[], + errorResponses: Record +): OpenApiPaths { + const paths: OpenApiPaths = {}; + for (const op of operations) { + const fullPath = joinPath(mountBase, op.path); + const pathItem = (paths[fullPath] ??= {}); + // OpenAPI operation keys are lowercased HTTP verbs. + pathItem[op.method.toLowerCase()] = buildOperation(op, errorResponses); + } + return paths; +} diff --git a/packages/plugin-api-docs/src/plugin-routes.ts b/packages/plugin-api-docs/src/plugin-routes.ts new file mode 100644 index 0000000000..9c20842420 --- /dev/null +++ b/packages/plugin-api-docs/src/plugin-routes.ts @@ -0,0 +1,69 @@ +/** + * Plugin-route derivation for the docs spec. + * + * Plugin HTTP routes are declarative by construction — `{ method, path, + * requiredPermission?, public? }` registered at boot — so this is a pure + * DERIVATION from the sdk's read-only `listPluginRoutes()` view: zero plugin + * action required to appear in the spec, and an optional `openapi?` annotation + * enriches the operation when present. + * + * @module plugin-routes + * @since alpha + */ +import type { PluginRouteInfo } from "@nextlyhq/plugin-sdk"; + +import type { DocsOperation } from "./descriptors"; + +/** + * Convert a plugin path using `:param` segments into an OpenAPI `{param}` path + * template. Only segment-leading colons are converted. + */ +function toOpenApiPath(path: string): string { + return path.replace(/\/:[A-Za-z_][\w]*/g, seg => `/{${seg.slice(2)}}`); +} + +/** + * Derive an auth mode from a route's secure-by-default flags: `public` wins; + * otherwise a `requiredPermission` makes it permission-gated; otherwise it is + * authenticated (secure by default still requires a session, just no slug). + */ +function deriveAuth(route: PluginRouteInfo): DocsOperation["auth"] { + if (route.public) return "public"; + if (route.requiredPermission) return "permission"; + return "authenticated"; +} + +/** + * Build a stable, unique operation id for a plugin route: plugin, verb, and a + * flattened form of the plugin-RELATIVE path (not the full path, which would + * repeat the plugin name). + */ +function pluginOperationId(route: PluginRouteInfo): string { + const tail = route.path + .replace(/[:{}]/g, "") + .replace(/[^A-Za-z0-9]+/g, ".") + .replace(/^\.+|\.+$/g, ""); + return `${route.pluginName}.${route.method.toLowerCase()}.${tail || "root"}`; +} + +/** + * Map the sdk's plugin-route view to documented operations. Pure: takes the + * routes as input so it is testable without the global registry. + */ +export function pluginRoutesToDocs( + routes: readonly PluginRouteInfo[] +): DocsOperation[] { + return routes.map(route => ({ + service: "plugins", + operation: pluginOperationId(route), + method: route.method, + path: toOpenApiPath(route.fullPath), + auth: deriveAuth(route), + permissionSlug: route.requiredPermission, + tag: `Plugin: ${route.pluginName}`, + // Fold the optional annotation through; absent fields stay undefined. + summary: route.openapi?.summary, + description: route.openapi?.description, + tags: route.openapi?.tags, + })); +} diff --git a/packages/plugin-api-docs/src/plugin.ts b/packages/plugin-api-docs/src/plugin.ts new file mode 100644 index 0000000000..8d1dda3b3e --- /dev/null +++ b/packages/plugin-api-docs/src/plugin.ts @@ -0,0 +1,369 @@ +/** + * `@nextlyhq/plugin-api-docs` — the first-party API documentation plugin. + * + * THE way to get OpenAPI docs for a Nextly app: install this plugin and it does + * everything at request time — scans the app's route files for where the API is + * mounted, pulls the admin REST operations through the core introspection seam + * (`listAdminRestOperations` via the plugin-sdk), folds in every registered + * plugin's routes (`listPluginRoutes`), assembles an OpenAPI 3.1 document with + * the enum-generated error component, and serves it plus an interactive Scalar + * reference. Core ships only the two small read-only introspection seams this + * consumes; all OpenAPI knowledge lives here. + * + * Opt-in and framework-agnostic: the docs page is plain HTML over the plugin + * route surface (zero `next`/`react` coupling). Scalar ships as a real + * dependency and is served by the plugin itself — no runtime CDN fetch, so the + * docs work offline, inside sandboxed webviews, and without leaking admin + * doc views to a third party. + * + * @module plugin + * @since alpha + */ +import { createRequire } from "node:module"; + +import { + definePlugin, + isAuthenticatedApiRequest, + listAdminRestOperations, + listContentSurfaces, + listPluginRoutes, + type PluginContributions, + type PluginDefinition, +} from "@nextlyhq/plugin-sdk"; + +import { restOperationsToDocs } from "./descriptors"; +import { + applyExcludes, + excludeOperationsByService, + type ExcludeOptions, +} from "./excludes"; +import type { ContentSurfaceLike, FieldLike } from "./fields"; +import { generateOpenApiDocument, type ContentConfig } from "./generate"; +import { applyMountOverrides, type MountOverride } from "./mount-overrides"; +import { pluginRoutesToDocs } from "./plugin-routes"; +import { scanAppDirectory } from "./scan"; +// The Scalar standalone bundle, vendored as a build-time text asset. Imported +// (not fs-resolved) because the host app's bundler pulls this dist into its +// own graph: it statically analyzes literal require.resolve("@scalar/...") +// and fails, and import.meta.url points into the bundle, not this package. +// A string constant is inert data to every bundler. +import scalarBundleSource from "./vendor/scalar-standalone.js.txt"; + +// Read the version from package.json so the plugin's declared version can never +// drift from what ships (mirrors the other first-party plugins). +const require = createRequire(import.meta.url); +const { version: PLUGIN_VERSION } = require("../package.json") as { + version: string; +}; + +/** Options for {@link apiDocsPlugin}. */ +export interface ApiDocsPluginOptions extends ExcludeOptions { + /** + * `'admin'` (default) serves the spec and docs admin-gated, under the plugin's + * route namespace. `'public'` makes the SPEC publicly readable (anyone can + * fetch the JSON); the docs page stays reachable but reflects whatever the + * spec route allows. + */ + visibility?: "admin" | "public"; + /** Sidebar label for the docs entry. Defaults to `"API Docs"`. */ + label?: string; + /** + * Base path the docs are served at, directly under the admin API root — + * `"/docs"` (default) serves the page at `/admin/api/docs`, the spec at + * `/admin/api/docs/spec.json`. Set it in nextly.config to move the whole + * surface (e.g. `"/api-docs"` → `/admin/api/api-docs`). Must start with "/" + * and must not name a system REST resource (refused at boot). + */ + docsPath?: string; + /** `info.title` in the generated document. Defaults to `"Nextly API"`. */ + title?: string; + /** Explicit mount declarations correcting or adding to the filesystem scan. */ + mounts?: readonly MountOverride[]; +} + +const PLUGIN_NAME = "@nextlyhq/plugin-api-docs"; +const DEFAULT_DOCS_PATH = "/docs"; + +/** Normalize a configured docs path: leading "/", no trailing "/". */ +function normalizeDocsPath(raw: string | undefined): string { + const path = (raw ?? DEFAULT_DOCS_PATH).replace(/\/+$/, "") || "/"; + if (!path.startsWith("/")) { + throw new Error( + `apiDocsPlugin: docsPath must start with "/" (got "${raw}")` + ); + } + return path; +} + +/** Route paths under the admin API root, derived from the docs base path. */ +function docsRoutePaths(docsPath: string): { + docs: string; + spec: string; + scalar: string; +} { + // A base of "/" would produce "//spec.json"; strip the trailing slash so the + // root mount serves "/spec.json" and "/scalar.js". + const base = docsPath.replace(/\/+$/, ""); + return { + docs: docsPath, + spec: `${base}/spec.json`, + scalar: `${base}/scalar.js`, + }; +} + +/** The full URL the spec route is served at (the Scalar page reads this). */ +function specUrl(docsPath: string): string { + return `/admin/api${docsRoutePaths(docsPath).spec}`; +} + +/** The full URL the plugin-served Scalar bundle is at. */ +function scalarJsUrl(docsPath: string): string { + return `/admin/api${docsRoutePaths(docsPath).scalar}`; +} + +/** + * Render the docs page shell. Scalar boots from the plugin-served bundle + * (`scriptUrl`) and reads the spec from `specUrl`; `data-url` is Scalar's + * documented configuration attribute. Exported (and pure) so the page content is + * testable without a request context. + */ +export function renderDocsHtml(specUrl: string, scriptUrl: string): string { + // Escape for safe embedding inside double-quoted HTML attributes. + const safeSpec = specUrl.replace(/"/g, """); + const safeScript = scriptUrl.replace(/"/g, """); + return ` + + + + +Nextly API Docs + + + + + + +`; +} + +/** + * Project the host config's content surfaces (collections / singles / + * field-groups) into the minimal shape the dynamic docs expansion reads: + * slug, labels, fields. The config's field objects carry far more than the + * docs need; only the wire-shaping options are projected. + */ +function toContent(config: unknown): ContentConfig { + const project = (list: unknown): ContentSurfaceLike[] | undefined => { + if (!Array.isArray(list)) return undefined; + return list + .filter( + (s): s is Record => typeof s === "object" && s !== null + ) + .map(s => { + const labels = s.labels as + { singular?: string; plural?: string } | undefined; + // A slug is a string (or number) by contract; anything else cannot be + // named safely, and defaulting it to "[object Object]" would produce a + // garbage path — skip instead. + const rawSlug = s.slug ?? s.name; + const slug = + typeof rawSlug === "string" || typeof rawSlug === "number" + ? String(rawSlug) + : ""; + return { + slug, + labels: labels && typeof labels === "object" ? labels : undefined, + fields: Array.isArray(s.fields) ? (s.fields as FieldLike[]) : [], + }; + }) + .filter(s => s.slug.length > 0); + }; + const cfg = (config ?? {}) as Record; + return { + collections: project(cfg.collections), + singles: project(cfg.singles), + fieldGroups: project(cfg.fieldGroups), + }; +} + +/** + * Resolve the content surfaces for dynamic expansion. Prefers the RUNTIME + * registry seam — it covers every origin (code-first, plugin-contributed, and + * collections/singles created DYNAMICALLY through the admin Schema Builder, + * read fresh from the database) — and falls back to the route context's config + * when the registry services are not up (or genuinely empty). + */ +async function resolveContent(config: unknown): Promise { + const surfaces = await listContentSurfaces(); + const fromRegistry: ContentConfig = { + collections: surfaces.collections.map(c => ({ + slug: c.slug, + labels: c.labels, + fields: c.fields as FieldLike[], + })), + singles: surfaces.singles.map(s => ({ + slug: s.slug, + labels: s.labels, + fields: s.fields as FieldLike[], + })), + }; + const hasRegistryData = + (fromRegistry.collections?.length ?? 0) > 0 || + (fromRegistry.singles?.length ?? 0) > 0; + // field-groups only exist as config declarations; carry them from the config + // view either way so component $refs keep resolving. + const cfg = toContent(config); + fromRegistry.fieldGroups = cfg.fieldGroups; + return hasRegistryData ? fromRegistry : cfg; +} + +/** + * Assemble the OpenAPI document for THIS app, on demand. Reads the sources + * (filesystem scan, admin REST seam, plugin routes, runtime content + * registries), applies the service excludes before generation and the + * path/code excludes after. On a published surface, an ANONYMOUS caller gets + * the public-only view (gated operations and content shapes stay private); + * a caller with a valid session/API key gets the full document. + */ +async function buildSpec( + options: ApiDocsPluginOptions, + config: unknown, + req: Request +): Promise> { + const scan = applyMountOverrides( + scanAppDirectory(process.cwd()), + options.mounts + ); + // Service excludes filter the operation lists the generator consumes; path + // and error-code excludes are applied to the assembled document. + const restOperations = excludeOperationsByService( + restOperationsToDocs(listAdminRestOperations()), + options.excludeServices + ); + const pluginOperations = excludeOperationsByService( + pluginRoutesToDocs(listPluginRoutes()), + options.excludeServices + ); + const doc = generateOpenApiDocument({ + scan, + restOperations, + pluginOperations, + // Runtime registries first (includes dynamically created content); the + // config view is the fallback. + content: await resolveContent(config), + info: { title: options.title }, + // Anonymous viewers of a published spec see the public surface only. On an + // admin-gated surface every caller already passed auth, so no filtering. + publicOnly: + options.visibility === "public" && + !(await isAuthenticatedApiRequest(req)), + }); + return applyExcludes(doc, options); +} + +/** + * Create the API Docs plugin. Register it directly in your config: + * + * @example + * ```ts + * import { defineConfig } from "nextly/config"; + * import { apiDocsPlugin } from "@nextlyhq/plugin-api-docs"; + * + * export default defineConfig({ + * plugins: [apiDocsPlugin()], + * }); + * ``` + * + * Contributes the docs surface directly at the admin API root (default + * `/admin/api/docs`, moved with `docsPath`): `GET ` (the Scalar + * reference page), `GET /spec.json` (the OpenAPI document), and + * `GET /scalar.js` (the self-hosted bundle), plus a sidebar entry + * linking to the docs page. All three are admin-gated by default; + * `visibility: "public"` publishes the WHOLE surface (page + spec + bundle) — + * publishing only the JSON would leave no way to read it. + */ +export function apiDocsPlugin( + options?: ApiDocsPluginOptions +): PluginDefinition { + const opts = options ?? {}; + const label = opts.label ?? "API Docs"; + const docsPath = normalizeDocsPath(opts.docsPath); + const paths = docsRoutePaths(docsPath); + const docsUrl = `/admin/api${docsPath}`; + // Secure by default: admin-gated unless the operator explicitly publishes. + // The page and bundle carry nothing the spec doesn't (an HTML shell and a + // public OSS library), so publishing means the whole surface — otherwise a + // logged-out visitor gets a 401 page for docs they were meant to read. + const isPublic = opts.visibility === "public"; + + const contributes: PluginContributions = { + routes: [ + { + method: "GET", + path: paths.docs, + // Served as a first-party admin API surface rather than under the + // plugin namespace — `/admin/api/docs` reads as product, not as plugin + // plumbing. + mount: "admin-api", + public: isPublic, + handler: () => + new Response( + renderDocsHtml(specUrl(docsPath), scalarJsUrl(docsPath)), + { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }, + } + ), + }, + { + method: "GET", + path: paths.spec, + mount: "admin-api", + public: isPublic, + handler: async (req, ctx) => + Response.json(await buildSpec(opts, ctx.config, req), { + headers: { "cache-control": "no-store" }, + }), + }, + { + // The Scalar bundle, served same-origin by the plugin — the library + // itself is public OSS, and the docs