Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/enable-generated-app-di-validation.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions packages/create-croco-app/src/tests/templates-build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ function getApiWorkerHandler(env: ApiWorkerEnv): ApiWorkerHandler {

const app = createApp({
controllers: [],
diValidation: "off",
middlewares: [
securityHeadersMiddleware(),
corsMiddleware({ origins: [webOrigin] }),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "reflect-metadata";
import { Component } from "@croco/framework-context";
import {
createSlidingWindowPolicy,
RateLimiter,
Expand All @@ -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[];
};
Expand All @@ -45,7 +48,6 @@ export function createCrocoApp(options: CreateCrocoAppOptions = {}) {

return createApp({
controllers: appControllers,
diValidation: "off",
globalFilters: [HttpExceptionFilter],
middlewares: [
securityHeadersMiddleware(),
Expand Down
2 changes: 2 additions & 0 deletions packages/create-croco-app/templates/ai-saas/README.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -31,14 +33,19 @@ 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"]),
);

return createApp({
controllers,
diValidation: "off",
diValidation: "warn",
diagnostics: {
providers: defaultSaasRuntime.diagnosticsCollector.getProviders(),
},
Expand All @@ -51,6 +58,64 @@ export function createCrocoApp() {
});
}

class BootstrapLogger implements ILogger {
constructor(private readonly bindings: Record<string, unknown> = {}) {}

debug(message: string, context?: Record<string, unknown>): void {
const outputContext = this.withBindings(context);
if (outputContext === undefined) {
console.debug(message);
return;
}
console.debug(message, outputContext);
}

info(message: string, context?: Record<string, unknown>): void {
const outputContext = this.withBindings(context);
if (outputContext === undefined) {
console.info(message);
return;
}
console.info(message, outputContext);
}

warn(message: string, context?: Record<string, unknown>): void {
const outputContext = this.withBindings(context);
if (outputContext === undefined) {
console.warn(message);
return;
}
console.warn(message, outputContext);
}

error(message: string, context?: Record<string, unknown> | 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<string, unknown>): ILogger {
return new BootstrapLogger({ ...this.bindings, ...bindings });
}

private withBindings(context?: Record<string, unknown>): Record<string, unknown> | undefined {
const outputContext = { ...this.bindings, ...context };
return Object.keys(outputContext).length === 0 ? undefined : outputContext;
}
}

function createApiRateLimitMiddleware(rateLimiter: RateLimiter): MiddlewareFunction {
return rateLimitHttpMiddleware({
rateLimiter,
Expand Down
2 changes: 2 additions & 0 deletions packages/create-croco-app/templates/saas/README.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,14 +32,19 @@ 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"]),
);

return createApp({
controllers,
diValidation: "off",
diValidation: "warn",
diagnostics: {
providers: defaultSaasRuntime.diagnosticsCollector.getProviders(),
},
Expand All @@ -50,6 +57,64 @@ export function createCrocoApp() {
});
}

class BootstrapLogger implements ILogger {
constructor(private readonly bindings: Record<string, unknown> = {}) {}

debug(message: string, context?: Record<string, unknown>): void {
const outputContext = this.withBindings(context);
if (outputContext === undefined) {
console.debug(message);
return;
}
console.debug(message, outputContext);
}

info(message: string, context?: Record<string, unknown>): void {
const outputContext = this.withBindings(context);
if (outputContext === undefined) {
console.info(message);
return;
}
console.info(message, outputContext);
}

warn(message: string, context?: Record<string, unknown>): void {
const outputContext = this.withBindings(context);
if (outputContext === undefined) {
console.warn(message);
return;
}
console.warn(message, outputContext);
}

error(message: string, context?: Record<string, unknown> | 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<string, unknown>): ILogger {
return new BootstrapLogger({ ...this.bindings, ...bindings });
}

private withBindings(context?: Record<string, unknown>): Record<string, unknown> | undefined {
const outputContext = { ...this.bindings, ...context };
return Object.keys(outputContext).length === 0 ? undefined : outputContext;
}
}

function createApiRateLimitMiddleware(rateLimiter: RateLimiter): MiddlewareFunction {
return rateLimitHttpMiddleware({
rateLimiter,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "reflect-metadata";
import { Component } from "@croco/framework-context";
import {
createSlidingWindowPolicy,
RateLimiter,
Expand All @@ -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[];
};
Expand All @@ -46,7 +49,6 @@ export function createCrocoApp(options: CreateCrocoAppOptions = {}) {

return createApp({
controllers: appControllers,
diValidation: "off",
globalFilters: [HttpExceptionFilter],
middlewares: [
securityHeadersMiddleware(),
Expand Down
Loading