From 7bf4fe33006bd07689862ad9cb6c80569fed7a92 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 1 Aug 2026 01:06:48 +0900 Subject: [PATCH] fix: keep generated app DI validation enabled --- .../enable-generated-app-di-validation.md | 5 ++ .../src/tests/templates-build.spec.ts | 25 +++++++ .../api-worker/src/index.ts | 1 - .../apps/api-server/src/app.ts.hbs | 4 +- .../templates/ai-saas/README.md.hbs | 2 + .../ai-saas/apps/api-server/src/app.ts.hbs | 69 ++++++++++++++++++- .../templates/saas/README.md.hbs | 2 + .../templates/saas/apps/api-server/src/app.ts | 69 ++++++++++++++++++- .../api-server/src/tests/SaasDemo.spec.ts | 69 ++++++++++++------- .../spa-be-split/apps/api-server/src/app.ts | 4 +- .../apps/api-server/src/tests/app.spec.ts | 46 +++++++++++++ 11 files changed, 266 insertions(+), 30 deletions(-) create mode 100644 .changeset/enable-generated-app-di-validation.md diff --git a/.changeset/enable-generated-app-di-validation.md b/.changeset/enable-generated-app-di-validation.md new file mode 100644 index 000000000..ecd07852a --- /dev/null +++ b/.changeset/enable-generated-app-di-validation.md @@ -0,0 +1,5 @@ +--- +"create-croco-app": patch +--- + +Generated applications now keep DI bootstrap validation enabled so missing providers surface during startup with actionable diagnostics. diff --git a/packages/create-croco-app/src/tests/templates-build.spec.ts b/packages/create-croco-app/src/tests/templates-build.spec.ts index f76dd068c..b94b881d4 100644 --- a/packages/create-croco-app/src/tests/templates-build.spec.ts +++ b/packages/create-croco-app/src/tests/templates-build.spec.ts @@ -1267,6 +1267,31 @@ describe.each(["spa-be-split", "saas", "ai-saas", "admin-console"])( }, ); +describe("Generated application DI bootstrap validation", () => { + it("does not disable DI validation in shipped templates", () => { + const files = readdirSync(TEMPLATES_DIR, { recursive: true, withFileTypes: true }).filter( + (entry) => entry.isFile(), + ); + + for (const file of files) { + const fullPath = join(file.parentPath, file.name); + const content = readFileSync(fullPath, "utf-8"); + + expect(content, `DI validation disabled in ${fullPath}`).not.toMatch( + /diValidation\s*:\s*["']off["']/, + ); + } + }); + + it("keeps a deterministic missing-provider diagnostic fixture", () => { + checkFileContains( + "spa-be-split", + ["apps", "api-server", "src", "tests", "app.spec.ts"], + /code:\s*"transports-http\/di-missing-provider"/, + ); + }); +}); + describe.each(["ssr-lambda", "container-fullstack"])("Compatibility fixture: %s", (template) => { it("should have required structure", () => { if (template === "ssr-lambda") { diff --git a/packages/create-croco-app/templates/addons/web-meta-vite-fullstack/api-worker/src/index.ts b/packages/create-croco-app/templates/addons/web-meta-vite-fullstack/api-worker/src/index.ts index 89dfae325..62538a800 100644 --- a/packages/create-croco-app/templates/addons/web-meta-vite-fullstack/api-worker/src/index.ts +++ b/packages/create-croco-app/templates/addons/web-meta-vite-fullstack/api-worker/src/index.ts @@ -54,7 +54,6 @@ function getApiWorkerHandler(env: ApiWorkerEnv): ApiWorkerHandler { const app = createApp({ controllers: [], - diValidation: "off", middlewares: [ securityHeadersMiddleware(), corsMiddleware({ origins: [webOrigin] }), diff --git a/packages/create-croco-app/templates/admin-console/apps/api-server/src/app.ts.hbs b/packages/create-croco-app/templates/admin-console/apps/api-server/src/app.ts.hbs index 967fc98d7..91515b856 100644 --- a/packages/create-croco-app/templates/admin-console/apps/api-server/src/app.ts.hbs +++ b/packages/create-croco-app/templates/admin-console/apps/api-server/src/app.ts.hbs @@ -1,4 +1,5 @@ import "reflect-metadata"; +import { Component } from "@croco/framework-context"; import { createSlidingWindowPolicy, RateLimiter, @@ -23,6 +24,8 @@ import { readEnv } from "./env"; const OPERATIONAL_RATE_LIMIT_BYPASS_PATHS = new Set(["/ops/health", "/ops/metrics"]); const controllers = [UserController, AdminController]; +Component()(HttpExceptionFilter); + export type CreateCrocoAppOptions = { readonly extraControllers?: readonly Constructor[]; }; @@ -45,7 +48,6 @@ export function createCrocoApp(options: CreateCrocoAppOptions = {}) { return createApp({ controllers: appControllers, - diValidation: "off", globalFilters: [HttpExceptionFilter], middlewares: [ securityHeadersMiddleware(), diff --git a/packages/create-croco-app/templates/ai-saas/README.md.hbs b/packages/create-croco-app/templates/ai-saas/README.md.hbs index 4df9c0326..69e8d7d5a 100644 --- a/packages/create-croco-app/templates/ai-saas/README.md.hbs +++ b/packages/create-croco-app/templates/ai-saas/README.md.hbs @@ -88,6 +88,8 @@ pnpm demo:smoke `pnpm demo:smoke` runs the base SaaS demo, operational smoke checks, and the AI SaaS smoke flow. `pnpm ai:smoke` runs only the AI portion. +The API bootstrap keeps DI validation enabled in `warn` mode. This preset manually composes the selected provider profile, while domain package barrels also register optional `@Component` classes that are not part of that runtime. Review bootstrap diagnostics and the root-based `pnpm di:verify` manifest together; switch `apps/api-server/src/app.ts` to `enforce` once the production composition supplies every registered component. + Intentional API contract changes should update `contract-graph.snapshot.json` with `pnpm contract:snapshot`, then update and commit the Project Map, manifest bundle, `openapi.json`, and provider-rpc client with `pnpm codegen`. CI should run `pnpm contract:verify` or `pnpm ci:contracts`; it checks these committed artifacts without rewriting them. Commit `contract-graph.coverage.json` only when audit artifacts need it. Run `pnpm di:graph` when intentionally regenerating the DI graph. `pnpm di:verify` validates the existing DI graph and Project Map artifacts without rewriting them, asserts required manifest fields, and runs `croco doctor --json`. diff --git a/packages/create-croco-app/templates/ai-saas/apps/api-server/src/app.ts.hbs b/packages/create-croco-app/templates/ai-saas/apps/api-server/src/app.ts.hbs index 6621fcd4a..d33c60444 100644 --- a/packages/create-croco-app/templates/ai-saas/apps/api-server/src/app.ts.hbs +++ b/packages/create-croco-app/templates/ai-saas/apps/api-server/src/app.ts.hbs @@ -1,5 +1,7 @@ import "reflect-metadata"; -import type { Constructor } from "@croco/framework-context"; +import { EntitlementManager } from "@croco/entitlements-core"; +import { Container, LOGGER_TOKEN } from "@croco/framework-context"; +import type { Constructor, ILogger } from "@croco/framework-context"; import { createSlidingWindowPolicy, RateLimiter, @@ -31,6 +33,11 @@ export function createCrocoDiGraphRoots(): readonly Constructor[] { } export function createCrocoApp() { + if (!Container.has(LOGGER_TOKEN)) { + Container.set(LOGGER_TOKEN, new BootstrapLogger()); + } + Container.set(EntitlementManager, defaultSaasRuntime.entitlementManager); + const rateLimiter = new RateLimiter( new SlidingWindowInMemoryStore(), new RateLimitKeyBuilder(["ip"]), @@ -38,7 +45,7 @@ export function createCrocoApp() { return createApp({ controllers, - diValidation: "off", + diValidation: "warn", diagnostics: { providers: defaultSaasRuntime.diagnosticsCollector.getProviders(), }, @@ -51,6 +58,64 @@ export function createCrocoApp() { }); } +class BootstrapLogger implements ILogger { + constructor(private readonly bindings: Record = {}) {} + + debug(message: string, context?: Record): void { + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.debug(message); + return; + } + console.debug(message, outputContext); + } + + info(message: string, context?: Record): void { + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.info(message); + return; + } + console.info(message, outputContext); + } + + warn(message: string, context?: Record): void { + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.warn(message); + return; + } + console.warn(message, outputContext); + } + + error(message: string, context?: Record | Error): void { + if (context instanceof Error) { + if (Object.keys(this.bindings).length === 0) { + console.error(message, context); + return; + } + console.error(message, this.bindings, context); + return; + } + + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.error(message); + return; + } + console.error(message, outputContext); + } + + child(bindings: Record): ILogger { + return new BootstrapLogger({ ...this.bindings, ...bindings }); + } + + private withBindings(context?: Record): Record | undefined { + const outputContext = { ...this.bindings, ...context }; + return Object.keys(outputContext).length === 0 ? undefined : outputContext; + } +} + function createApiRateLimitMiddleware(rateLimiter: RateLimiter): MiddlewareFunction { return rateLimitHttpMiddleware({ rateLimiter, diff --git a/packages/create-croco-app/templates/saas/README.md.hbs b/packages/create-croco-app/templates/saas/README.md.hbs index 089af0edc..e05a46e02 100644 --- a/packages/create-croco-app/templates/saas/README.md.hbs +++ b/packages/create-croco-app/templates/saas/README.md.hbs @@ -25,6 +25,8 @@ pnpm failure-drill:smoke `ops:smoke`는 diagnostics를 token mode로 켜고 unauthenticated 요청이 `403`으로 거부되는지 확인한 뒤, `croco ops check` contract로 `/health`, `/ready`, `/diagnostics`를 검증합니다. `failure-drill:smoke`는 기존 credential-free failure catalog와 함께 provider 환경 누락, telemetry exporter 장애, DI provider/scope 오류, route validation, rate limit, auth verifier 장애, webhook signature 오류를 실제 public boundary에서 실행합니다. 각 drill은 안정적인 Problem/diagnostic code, response shape, provenance, recovery action이 없으면 실패하며 deterministic 결과를 `ci-reports/failure-drills/operational.json`, `ci-reports/failure-drills/operational.md`에 기록합니다. +API bootstrap은 DI validation을 `warn`으로 켭니다. 이 preset은 선택한 provider profile을 수동 조립하지만 domain package barrel이 선택되지 않은 optional `@Component`도 등록하므로 전체-container `enforce`는 해당 optional provider까지 요구합니다. 부팅 경고와 `pnpm di:verify`의 root 기반 manifest를 함께 확인하고, production composition이 모든 등록 component를 제공하는 경우 `apps/api-server/src/app.ts`에서 `enforce`로 올리세요. + ## Usage Dashboard ```bash diff --git a/packages/create-croco-app/templates/saas/apps/api-server/src/app.ts b/packages/create-croco-app/templates/saas/apps/api-server/src/app.ts index e3114d33e..06d5be3d0 100644 --- a/packages/create-croco-app/templates/saas/apps/api-server/src/app.ts +++ b/packages/create-croco-app/templates/saas/apps/api-server/src/app.ts @@ -1,5 +1,7 @@ import "reflect-metadata"; -import type { Constructor } from "@croco/framework-context"; +import { EntitlementManager } from "@croco/entitlements-core"; +import { Container, LOGGER_TOKEN } from "@croco/framework-context"; +import type { Constructor, ILogger } from "@croco/framework-context"; import { createSlidingWindowPolicy, RateLimiter, @@ -30,6 +32,11 @@ export function createCrocoDiGraphRoots(): readonly Constructor[] { } export function createCrocoApp() { + if (!Container.has(LOGGER_TOKEN)) { + Container.set(LOGGER_TOKEN, new BootstrapLogger()); + } + Container.set(EntitlementManager, defaultSaasRuntime.entitlementManager); + const rateLimiter = new RateLimiter( new SlidingWindowInMemoryStore(), new RateLimitKeyBuilder(["ip"]), @@ -37,7 +44,7 @@ export function createCrocoApp() { return createApp({ controllers, - diValidation: "off", + diValidation: "warn", diagnostics: { providers: defaultSaasRuntime.diagnosticsCollector.getProviders(), }, @@ -50,6 +57,64 @@ export function createCrocoApp() { }); } +class BootstrapLogger implements ILogger { + constructor(private readonly bindings: Record = {}) {} + + debug(message: string, context?: Record): void { + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.debug(message); + return; + } + console.debug(message, outputContext); + } + + info(message: string, context?: Record): void { + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.info(message); + return; + } + console.info(message, outputContext); + } + + warn(message: string, context?: Record): void { + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.warn(message); + return; + } + console.warn(message, outputContext); + } + + error(message: string, context?: Record | Error): void { + if (context instanceof Error) { + if (Object.keys(this.bindings).length === 0) { + console.error(message, context); + return; + } + console.error(message, this.bindings, context); + return; + } + + const outputContext = this.withBindings(context); + if (outputContext === undefined) { + console.error(message); + return; + } + console.error(message, outputContext); + } + + child(bindings: Record): ILogger { + return new BootstrapLogger({ ...this.bindings, ...bindings }); + } + + private withBindings(context?: Record): Record | undefined { + const outputContext = { ...this.bindings, ...context }; + return Object.keys(outputContext).length === 0 ? undefined : outputContext; + } +} + function createApiRateLimitMiddleware(rateLimiter: RateLimiter): MiddlewareFunction { return rateLimitHttpMiddleware({ rateLimiter, diff --git a/packages/create-croco-app/templates/saas/apps/api-server/src/tests/SaasDemo.spec.ts b/packages/create-croco-app/templates/saas/apps/api-server/src/tests/SaasDemo.spec.ts index 1998229b2..e8a62f17d 100644 --- a/packages/create-croco-app/templates/saas/apps/api-server/src/tests/SaasDemo.spec.ts +++ b/packages/create-croco-app/templates/saas/apps/api-server/src/tests/SaasDemo.spec.ts @@ -1,13 +1,12 @@ +import { Container } from "typedi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import type { CheckoutResult } from "@croco/billing-core"; +import { Container as CrocoContainer, LOGGER_TOKEN } from "@croco/framework-context"; +import type { ILogger } from "@croco/framework-context"; import { InMemoryIdempotencyStore } from "@croco/idempotency-core"; -import { - DuplicateRecordProblem, - IdempotencyManager, - type PendingMeteringDelivery, -} from "@croco/metering-core"; -import { createTestKernel } from "@croco/testing"; -import { Container } from "typedi"; -import { beforeEach, describe, expect, it } from "vitest"; +import { DuplicateRecordProblem, IdempotencyManager } from "@croco/metering-core"; +import type { PendingMeteringDelivery } from "@croco/metering-core"; import { createCrocoApp } from "../app"; import { JobsController } from "../controllers/JobsController"; import { assertDemoEndpointsEnabled, SaasController } from "../controllers/SaasController"; @@ -115,23 +114,47 @@ describe("SaaS golden path demo", () => { expect(distinct.checkoutUrl).not.toBe(first.checkoutUrl); }); - it("boots application-fidelity tests through the exported production bootstrap", async () => { - await using kernel = await createTestKernel({ - bootstrap: createCrocoApp, - fidelity: "application", - // The template production bootstrap intentionally configures diValidation: "off". - validation: { di: "off" }, - }); + it("boots through the exported production bootstrap with documented DI validation", async () => { + const previousNodeEnv = process.env.NODE_ENV; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + process.env.NODE_ENV = "production"; + + try { + const app = createCrocoApp(); + const response = await app.fetch(new Request("http://localhost/health")); + + expect(response.status).toBe(200); + expect(app.describeBootstrapValidationPolicy()).toEqual({ + di: "warn", + security: "enforce", + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("DI bootstrap validation failed")); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Register the missing provider(s)"), + ); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + warn.mockRestore(); + } + }); - const response = await kernel.http.get("/health"); + it("preserves a caller-provided bootstrap logger", () => { + const logger: ILogger = { + child: () => logger, + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + CrocoContainer.set(LOGGER_TOKEN, logger); - expect(response.status).toBe(200); - expect(kernel.app).toBeDefined(); - expect(kernel.fidelity).toEqual({ - boot: "application", - runtime: "node", - validation: "overridden", - }); + createCrocoApp(); + + expect(CrocoContainer.get(LOGGER_TOKEN)).toBe(logger); }); it("creates tenant and owner membership", async () => { diff --git a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/app.ts b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/app.ts index 5ee463d06..17527a1c2 100644 --- a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/app.ts +++ b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/app.ts @@ -1,4 +1,5 @@ import "reflect-metadata"; +import { Component } from "@croco/framework-context"; import { createSlidingWindowPolicy, RateLimiter, @@ -22,6 +23,8 @@ import { readEnv } from "./env"; const OPERATIONAL_RATE_LIMIT_BYPASS_PATHS = new Set(["/ops/health", "/ops/metrics"]); const controllers = [UserController]; +Component()(HttpExceptionFilter); + export type CreateCrocoAppOptions = { readonly extraControllers?: readonly Constructor[]; }; @@ -46,7 +49,6 @@ export function createCrocoApp(options: CreateCrocoAppOptions = {}) { return createApp({ controllers: appControllers, - diValidation: "off", globalFilters: [HttpExceptionFilter], middlewares: [ securityHeadersMiddleware(), diff --git a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/tests/app.spec.ts b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/tests/app.spec.ts index ffb88a376..775dbd2d2 100644 --- a/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/tests/app.spec.ts +++ b/packages/create-croco-app/templates/spa-be-split/apps/api-server/src/tests/app.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { Component } from "@croco/framework-context"; import type { Guard } from "@croco/framework-context"; import { Controller, Get, UseGuards } from "@croco/protocols-rest"; import type { ExecutionContext } from "@croco/protocols-rest"; @@ -7,12 +8,14 @@ import { getUserAuditEntries, resetUserRuntimeForTests } from "../users"; const protectedRouteToken = "generated-smoke-token"; +@Component() class ProtectedRouteGuard implements Guard { canActivate(context: ExecutionContext): boolean { return context.getRequest().headers.get("authorization") === `Bearer ${protectedRouteToken}`; } } +@Component() @Controller("/protected-smoke") class ProtectedSmokeController { @Get() @@ -22,6 +25,14 @@ class ProtectedSmokeController { } } +@Controller("/missing-provider-smoke") +class MissingProviderController { + @Get() + read() { + return { ok: true }; + } +} + describe("API server", () => { beforeEach(() => { resetUserRuntimeForTests(); @@ -102,4 +113,39 @@ describe("API server", () => { expect(allowed.status).toBe(200); expect(allowedBody).toEqual({ ok: true }); }); + + it("reports an unregistered template provider during bootstrap", () => { + const previousDiValidation = process.env.CROCO_HTTP_DI_VALIDATION; + process.env.CROCO_HTTP_DI_VALIDATION = "enforce"; + + try { + const app = createCrocoApp({ extraControllers: [MissingProviderController] }); + + let error: unknown; + try { + app.lambdaHandler(); + } catch (caught) { + error = caught; + } + + expect(error).toMatchObject({ + code: "transports-http/di-bootstrap-validation", + extensions: { + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: "transports-http/di-missing-provider", + provider: "MissingProviderController", + usages: ["controller MissingProviderController"], + }), + ]), + }, + }); + } finally { + if (previousDiValidation === undefined) { + delete process.env.CROCO_HTTP_DI_VALIDATION; + } else { + process.env.CROCO_HTTP_DI_VALIDATION = previousDiValidation; + } + } + }); });