import type { Tina4Request, Tina4Response, RouteDefinition } from "../../core/src/index.js";
// The UI assets load from a CDN by default (a documented architecture decision —
// we don't vendor ~1.4MB of swagger-ui-dist, to stay small). jsdelivr
// (SWAG-CDN-NO-SRI, ADR-0004) — the SAME default as the Python and Ruby
// masters, so all four frameworks pull the UI bundle from one CDN rather than
// splitting jsdelivr/unpkg. Air-gapped deployments point TINA4_SWAGGER_UI_CDN
// at a self-hosted mirror (a base URL serving swagger-ui.css + swagger-ui-bundle.js).
function swaggerUiCdn(): string {
return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").replace(/\/+$/, "");
}
const SWAGGER_UI_HTML = (specUrl: string) => `
Tina4 API Documentation
`;
/**
* Whether the Swagger UI + spec routes should be registered at boot.
*
* Default: enabled when `TINA4_DEBUG=true`, disabled otherwise. Operators
* can force either state with `TINA4_SWAGGER_ENABLED=true|false`. Matches
* Python parity: dev-only by default to keep production attack surface
* minimal, but easy to expose intentionally for public APIs.
*/
export function swaggerEnabled(): boolean {
const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase();
if (raw === "") {
const debug = (process.env.TINA4_DEBUG ?? "").trim().toLowerCase();
return ["true", "1", "yes", "on"].includes(debug);
}
return ["true", "1", "yes", "on"].includes(raw);
}
export function createSwaggerRoutes(
getSpec: () => unknown
): RouteDefinition[] {
const serveUi = async (_req: Tina4Request, res: Tina4Response): Promise => {
res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
};
return [
{
method: "GET",
pattern: "/swagger",
handler: serveUi,
},
{
// The trailing-slash form, registered rather than left to fall through.
//
// Matching "/foo/" against a "/foo" route is opt-in via
// TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
// this route and was answered by the framework-bundled
// public/swagger/index.html instead. That mattered twice over. It used to
// be a 200 carrying a permanently empty UI, because the bundled file asked
// for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
// And it is a SECOND Swagger UI implementation: the bundled one hardcodes
// cdnjs, while the page this handler renders loads from
// TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
// local mirror silently kept reaching cdnjs on this one path.
//
// Registering it keeps the fix inside swagger rather than changing how
// every route treats trailing slashes, satisfies the shared contract that
// already requires a 200 here, and matches python and ruby, which both
// serve /swagger and /swagger/ with no env var set. Excluded from the
// generated document by INTERNAL_PREFIXES like /swagger itself.
method: "GET",
pattern: "/swagger/",
handler: serveUi,
},
{
method: "GET",
pattern: "/swagger/openapi.json",
handler: async (_req: Tina4Request, res: Tina4Response) => {
res.json(getSpec());
},
},
];
}