From 550b2fc316f4d339d93104642c6c4015cb283c1e Mon Sep 17 00:00:00 2001 From: Iceeyyou2 <126117799+Iceeyyou2@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:27:45 +0000 Subject: [PATCH] fix: resolve issues #298, #304, #309, #318 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #298 — Restrict GET /metrics - Add MetricsTokenGuard with bearer-token auth (METRICS_TOKEN env var) - Fail-closed in production when METRICS_TOKEN is unset (returns 401) - Allow unauthenticated scraping in non-production for local Prometheus stacks - Wire guard onto MetricsController with @UseGuards - Add METRICS_TOKEN to env.validation.ts and all .env.*.example files - Add MetricsTokenGuard unit tests (7 cases) #304 — Sanitise HttpExceptionFilter custom-shaped-body passthrough - Introduce CUSTOM_BODY_ALLOWLIST (error, intentId, fillAmount, minDstAmount) - Strip unlisted fields and emit logger.warn so contributors learn about leaks - Inject requestId into custom-shaped responses (was missing) - Add spec cases: allowlist forwarding, unknown-field stripping, requestId injection #309 — Add .github/ISSUE_TEMPLATE/ directory - bug-report.yml: structured bug report with reproduction steps, environment - feature-request.yml: problem/solution/alternatives with area dropdown - contributor-claim.yml: Drips Wave item claim form with implementation plan - config.yml: disable blank issues; redirect security reports to advisory flow #318 — OpenAPI drift-check CI job - Add openapi-drift job to ci.yml (runs on pull_request only) - Builds the app, regenerates the client, and diffs src/generated/ - Fails with an actionable error if openapi.json or api-types.ts are stale --- .env.example | 5 ++ .env.mainnet.example | 7 ++ .env.testnet.example | 5 ++ .github/ISSUE_TEMPLATE/bug-report.yml | 81 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/contributor-claim.yml | 61 +++++++++++++++ .github/ISSUE_TEMPLATE/feature-request.yml | 63 +++++++++++++++ .github/workflows/ci.yml | 75 ++++++++++++++++++ src/common/http-exception.filter.spec.ts | 39 +++++++++- src/common/http-exception.filter.ts | 41 +++++++++- src/config/env.validation.ts | 9 +++ src/metrics/metrics-token.guard.spec.ts | 78 +++++++++++++++++++ src/metrics/metrics-token.guard.ts | 56 ++++++++++++++ src/metrics/metrics.controller.ts | 19 ++++- 14 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/contributor-claim.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 src/metrics/metrics-token.guard.spec.ts create mode 100644 src/metrics/metrics-token.guard.ts diff --git a/.env.example b/.env.example index c139b0b..dc7e0fa 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,11 @@ LOG_SERVICE_NAME=vortex-backend # Sentry DSN for error reporting. Leave blank to disable Sentry entirely. SENTRY_DSN= +# Bearer token for GET /metrics (issue #298). +# Empty = unauthenticated in dev/test; production always requires a value. +# Generate with: openssl rand -hex 32 +METRICS_TOKEN= + # Optional log shipping to a central collector. Off by default so local dev and # CI stay stdout-only. When enabled, LOG_SHIPPING_HOST is required. LOG_SHIPPING_ENABLED=false diff --git a/.env.mainnet.example b/.env.mainnet.example index c0a6275..d56cf11 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -103,6 +103,13 @@ KILLSWITCH_PERSISTENCE=prisma # Recommended in production: set to your Sentry project DSN. SENTRY_DSN= +# Bearer token for GET /metrics (issue #298). REQUIRED in production. +# Without this the endpoint returns 401 (fail-closed). Generate with: +# openssl rand -hex 32 +# Configure your Prometheus scrape job with: +# authorization: { type: Bearer, credentials: } +METRICS_TOKEN= + # info is the right level for production — "debug" is too noisy. LOG_LEVEL=info diff --git a/.env.testnet.example b/.env.testnet.example index f8d80bf..987dde9 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -87,6 +87,11 @@ KILLSWITCH_PERSISTENCE=prisma # Leave blank to disable Sentry error reporting. SENTRY_DSN= +# Bearer token for GET /metrics (issue #298). +# Leave blank for unauthenticated local Prometheus scraping. +# In production: generate with `openssl rand -hex 32` and set a real value. +METRICS_TOKEN= + # debug | info | warn | error (defaults to "debug" in development) LOG_LEVEL=debug diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..6682b0a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,81 @@ +name: Bug Report +description: Report a bug you found while using or working on vortex-backend. +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! + Please fill out as much of the form as possible so we can reproduce and fix it quickly. + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear and concise description of the bug. + placeholder: Describe what went wrong. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + description: What should have happened instead? + placeholder: Describe the expected behaviour. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Minimal steps that reliably trigger the bug. + placeholder: | + 1. Start the server with `npm run dev` + 2. Send `POST /api/v1/intents` with body `{ ... }` + 3. See error ... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs or error output + description: Paste any stack traces, structured log lines, or Sentry event IDs here. + render: shell + validations: + required: false + + - type: input + id: version + attributes: + label: Version / commit SHA + description: What version or git SHA are you running? + placeholder: e.g. 0.1.0 or abc1234 + validations: + required: false + + - type: dropdown + id: environment + attributes: + label: Environment + options: + - Local development + - Testnet + - Mainnet / production + - CI + - Other + validations: + required: true + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: I have included enough information to reproduce the issue. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3d86cdf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/vortex-protocol/vortex-backend/security/advisories/new + about: Please report security issues via GitHub's private vulnerability reporting — do not open a public issue. diff --git a/.github/ISSUE_TEMPLATE/contributor-claim.yml b/.github/ISSUE_TEMPLATE/contributor-claim.yml new file mode 100644 index 0000000..ceef1ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contributor-claim.yml @@ -0,0 +1,61 @@ +name: Contributor Claim (Drips Wave) +description: Signal that you are picking up a numbered item from issues.md. +title: "[Claim]: # — " +labels: ["claim", "drips-wave"] +body: + - type: markdown + attributes: + value: | + Use this form to claim a numbered issue from + [issues.md](../blob/main/issues.md) so other contributors know it is + being worked on. One claim per person per issue; duplicate claims on + the same item will be closed. + + - type: input + id: issue_number + attributes: + label: Issue number (from issues.md) + description: The `#NNN` identifier listed in issues.md. + placeholder: "e.g. #298" + validations: + required: true + + - type: input + id: issue_title + attributes: + label: Issue title + description: The title as it appears in issues.md. + placeholder: "e.g. Restrict access to GET /metrics" + validations: + required: true + + - type: textarea + id: plan + attributes: + label: Brief implementation plan + description: | + A 2–5 sentence summary of how you plan to tackle it. + This is to surface approach mismatches early, not to gate your work. + placeholder: | + I'll add a MetricsTokenGuard that reads METRICS_TOKEN from env … + validations: + required: true + + - type: input + id: eta + attributes: + label: Estimated PR date + description: Rough target (week or sprint). We use this to reclaim stale items. + placeholder: "e.g. 2025-11-15 or 'within 2 weeks'" + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: The item is not already assigned or claimed in issues.md / open PRs. + required: true + - label: I have read CONTRIBUTING.md and understand the PR checklist. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..36f18a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,63 @@ +name: Feature Request +description: Propose a new feature or improvement for vortex-backend. +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe the problem you're trying to solve + and your proposed solution so we can discuss it effectively. + + - type: textarea + id: problem + attributes: + label: Problem statement + description: | + What problem does this feature solve? Why is the current behaviour insufficient? + placeholder: | + As a solver, I need to ... because currently ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the feature you'd like. Include API/interface sketches where relevant. + placeholder: Add a new endpoint / guard / config option that ... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What other approaches did you consider and why did you rule them out? + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area + options: + - Intents / solver flow + - WebSocket / real-time feed + - Soroban / on-chain integration + - Auth / access control + - Observability / metrics + - Developer experience / CI + - Other + validations: + required: true + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: I am willing to help implement or review this feature. + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d56ad1e..92082e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -580,6 +580,81 @@ jobs: push: false tags: vortex-backend:ci + # ── OpenAPI drift check (issue #318) ────────────────────────────────────── + # Verifies that src/generated/openapi.json (and the api-types.ts it drives) + # still matches what the current controllers would produce. A PR that + # changes a controller DTO or route without re-running `npm run generate:client` + # will fail here before stale types ship to SDK consumers. + # + # The job runs only on pull_request events so we don't block main pushes that + # are the *result* of running the generator. The sdk-publish job below + # re-generates on tags anyway, so production is always up-to-date. + openapi-drift: + name: OpenAPI drift check + runs-on: ubuntu-latest + needs: backend + if: github.event_name == 'pull_request' + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Restore cached Prisma client + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: node_modules/.prisma + key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} + + - name: Generate Prisma client + run: npm run db:generate + + - name: Run database migrations + run: npm run db:migrate:prod + + # Build first — generate:client requires dist/ to boot the throw-away app. + - name: Build + run: npm run build + + # Re-generate the client from the current controllers into a temp directory, + # then diff against the checked-in src/generated/. Any difference means + # a controller change was not followed by `npm run generate:client`. + - name: Regenerate OpenAPI client + run: npm run generate:client + + - name: Check for OpenAPI drift + run: | + if ! git diff --exit-code src/generated/; then + echo "" + echo "::error::src/generated/ is out of date. Run \`npm run generate:client\` locally," + echo "::error::commit the updated files, and push again." + echo "" + git diff src/generated/ + exit 1 + fi + echo "✅ src/generated/ is in sync with the current controllers." + sdk-publish: name: Publish generated SDK runs-on: ubuntu-latest diff --git a/src/common/http-exception.filter.spec.ts b/src/common/http-exception.filter.spec.ts index d4b182c..4e59226 100644 --- a/src/common/http-exception.filter.spec.ts +++ b/src/common/http-exception.filter.spec.ts @@ -1,14 +1,16 @@ import { HttpException, HttpStatus, ArgumentsHost } from "@nestjs/common"; import { HttpExceptionFilter } from "./http-exception.filter"; import * as sentryModule from "./sentry"; +import { logger } from "./logger"; // ── helpers ─────────────────────────────────────────────────────────────────── -function makeHost(json: jest.Mock, requestId?: string): ArgumentsHost { +function makeHost(json: jest.Mock, requestId?: string, setHeader?: jest.Mock): ArgumentsHost { return { switchToHttp: () => ({ getResponse: () => ({ status: (_code: number) => ({ json }), + setHeader: setHeader ?? jest.fn(), }), getRequest: () => (requestId ? { requestId } : {}), }), @@ -124,4 +126,39 @@ describe("HttpExceptionFilter", () => { expect(json).toHaveBeenCalledWith({ error: "boom" }); }); }); + + describe("custom-shaped body passthrough (issue #304)", () => { + it("forwards allowlisted fields from a custom-shaped exception body", () => { + const host = makeHost(json); + const body = { error: "fill-check failed", intentId: "abc-123", fillAmount: "100", minDstAmount: "90" }; + filter.catch(new HttpException(body, HttpStatus.BAD_REQUEST), host); + expect(json).toHaveBeenCalledWith({ + error: "fill-check failed", + intentId: "abc-123", + fillAmount: "100", + minDstAmount: "90", + }); + }); + + it("strips unknown fields from a custom-shaped body and logs a warning", () => { + const host = makeHost(json); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const warnSpy = jest.spyOn(logger, "warn").mockImplementation((() => logger) as any); + const body = { error: "something failed", intentId: "abc-123", internalDebugField: "secret" }; + filter.catch(new HttpException(body, HttpStatus.BAD_REQUEST), host); + const response = json.mock.calls[0][0] as Record; + expect(response).not.toHaveProperty("internalDebugField"); + expect(response).toHaveProperty("error"); + expect(response).toHaveProperty("intentId"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("internalDebugField")); + warnSpy.mockRestore(); + }); + + it("injects requestId into a custom-shaped body response", () => { + const host = makeHost(json, "req-xyz-456"); + const body = { error: "fill-check failed", intentId: "abc-123" }; + filter.catch(new HttpException(body, HttpStatus.BAD_REQUEST), host); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ requestId: "req-xyz-456" })); + }); + }); }); diff --git a/src/common/http-exception.filter.ts b/src/common/http-exception.filter.ts index a033bd1..0cb6bdb 100644 --- a/src/common/http-exception.filter.ts +++ b/src/common/http-exception.filter.ts @@ -2,6 +2,29 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/co import { captureException } from "./sentry"; import { logger } from "./logger"; +/** + * Fields that are explicitly allowed to pass through in a custom-shaped exception + * body (i.e. the `b.error && !b.statusCode` branch). + * + * Any key NOT in this set will be stripped and a warning emitted in development + * so the author learns about the leak before it reaches production. In + * production the field is silently dropped so no internal detail escapes. + * + * Today's only known usage is IntentsController.fill(): + * throw new BadRequestException({ error, intentId, minDstAmount, fillAmount }) + * — all four fields are intentionally public. + * + * To expose a new field from a custom-shaped exception, add its name here and + * document why it is safe to return to API consumers. Closes #304. + */ +const CUSTOM_BODY_ALLOWLIST = new Set([ + "error", // human-readable error message (required) + "intentId", // the intent that failed — already in the URL, safe to echo + "fillAmount", // the amount the solver attempted — safe to echo to the solver + "minDstAmount", // the required minimum — safe to echo to the solver + "requestId", // injected below; listed for clarity +]); + interface JsonResponse { status: (code: number) => { json: (body: unknown) => void }; setHeader?: (name: string, value: string) => void; @@ -57,8 +80,24 @@ export class HttpExceptionFilter implements ExceptionFilter { // Custom-shaped bodies passed directly to an exception constructor, // e.g. new BadRequestException({ error: "...", fillAmount, minDstAmount }) + // Only fields on CUSTOM_BODY_ALLOWLIST are forwarded to the client. + // Any extra field is stripped and a warning is emitted so that future + // contributors learn about the leak before it reaches production. + // Closes #304. if (typeof b.error === "string" && !b.statusCode) { - response.status(status).json(b); + const sanitized: Record = {}; + for (const [key, value] of Object.entries(b)) { + if (CUSTOM_BODY_ALLOWLIST.has(key)) { + sanitized[key] = value; + } else { + logger.warn( + `HttpExceptionFilter: custom exception body contains unexpected field "${key}" — ` + + "it has been stripped from the response. If this field is safe to expose to " + + "API consumers, add it to CUSTOM_BODY_ALLOWLIST in http-exception.filter.ts.", + ); + } + } + response.status(status).json(addRequestId(sanitized, requestId)); return; } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 61f9d6c..ac39f76 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -98,6 +98,15 @@ export const envValidationSchema = Joi.object({ // Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry. SENTRY_DSN: Joi.string().uri().allow("").default(""), + // Bearer token that guards GET /metrics (issue #298). + // When set, Prometheus scrape jobs must supply: + // Authorization: Bearer + // When empty (the default): + // - non-production: unauthenticated scraping allowed (local dev Prometheus) + // - production: endpoint returns 401 (fail-closed — set the token before deploying) + // Generate with: openssl rand -hex 32 + METRICS_TOKEN: Joi.string().allow("").default(""), + // Winston log level. Defaults to "debug" in dev/test and "info" in production. LOG_LEVEL: Joi.string() .valid("error", "warn", "info", "http", "verbose", "debug", "silly") diff --git a/src/metrics/metrics-token.guard.spec.ts b/src/metrics/metrics-token.guard.spec.ts new file mode 100644 index 0000000..906df60 --- /dev/null +++ b/src/metrics/metrics-token.guard.spec.ts @@ -0,0 +1,78 @@ +import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { MetricsTokenGuard } from "./metrics-token.guard"; + +function makeContext(authHeader?: string): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ + headers: authHeader ? { authorization: authHeader } : {}, + }), + }), + } as unknown as ExecutionContext; +} + +describe("MetricsTokenGuard", () => { + let guard: MetricsTokenGuard; + const originalEnv = process.env; + + beforeEach(() => { + guard = new MetricsTokenGuard(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("no METRICS_TOKEN configured", () => { + beforeEach(() => { + delete process.env.METRICS_TOKEN; + }); + + it("allows unauthenticated access in development", () => { + process.env.NODE_ENV = "development"; + expect(guard.canActivate(makeContext())).toBe(true); + }); + + it("allows unauthenticated access in test", () => { + process.env.NODE_ENV = "test"; + expect(guard.canActivate(makeContext())).toBe(true); + }); + + it("denies access in production (fail-closed)", () => { + process.env.NODE_ENV = "production"; + expect(() => guard.canActivate(makeContext())).toThrow(UnauthorizedException); + }); + }); + + describe("METRICS_TOKEN configured", () => { + beforeEach(() => { + process.env.METRICS_TOKEN = "secret-token-abc"; + }); + + it("allows a request with the correct bearer token", () => { + expect(guard.canActivate(makeContext("Bearer secret-token-abc"))).toBe(true); + }); + + it("denies a request with the wrong bearer token", () => { + expect(() => guard.canActivate(makeContext("Bearer wrong-token"))).toThrow( + UnauthorizedException, + ); + }); + + it("denies a request with no Authorization header", () => { + expect(() => guard.canActivate(makeContext())).toThrow(UnauthorizedException); + }); + + it("denies a request with a non-Bearer scheme", () => { + expect(() => guard.canActivate(makeContext("Basic secret-token-abc"))).toThrow( + UnauthorizedException, + ); + }); + + it("works the same in production when token is set", () => { + process.env.NODE_ENV = "production"; + expect(guard.canActivate(makeContext("Bearer secret-token-abc"))).toBe(true); + }); + }); +}); diff --git a/src/metrics/metrics-token.guard.ts b/src/metrics/metrics-token.guard.ts new file mode 100644 index 0000000..b8c949b --- /dev/null +++ b/src/metrics/metrics-token.guard.ts @@ -0,0 +1,56 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; + +/** + * Guards GET /metrics behind a bearer token. + * + * When METRICS_TOKEN is set in the environment the client must supply it as: + * Authorization: Bearer + * + * When METRICS_TOKEN is empty or absent (the default) the guard still blocks + * all external traffic in production (NODE_ENV=production) so that an operator + * who forgets to set the token does not accidentally expose the endpoint. In + * non-production environments an empty token allows unauthenticated scraping + * from localhost — useful for local Prometheus dev stacks. + * + * Why a bearer token rather than an IP allowlist? + * An IP allowlist requires infrastructure-level knowledge (Prometheus pod + * CIDR, sidecar addresses) that varies between environments and is awkward + * to configure via env vars. A shared secret is portable, easy to rotate, + * and understood by every Prometheus scrape config via + * `authorization: { type: Bearer, credentials: }`. + * + * Closes #298. + */ +@Injectable() +export class MetricsTokenGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const metricsToken = process.env.METRICS_TOKEN ?? ""; + const nodeEnv = process.env.NODE_ENV ?? "development"; + + // In production without a configured token: always deny. Fail closed so + // a misconfigured deploy never silently exposes internal metrics. + if (!metricsToken && nodeEnv === "production") { + throw new UnauthorizedException( + "GET /metrics is not available: set METRICS_TOKEN to enable authenticated scraping. " + + "See docs/runbooks/metrics.md.", + ); + } + + // No token configured outside production: allow (supports local dev + // Prometheus stacks that scrape without credentials). + if (!metricsToken) { + return true; + } + + // Token configured: require Authorization: Bearer . + const request = context.switchToHttp().getRequest<{ headers: Record }>(); + const authHeader = request.headers["authorization"] ?? ""; + const [scheme, provided] = authHeader.split(" "); + + if (scheme !== "Bearer" || provided !== metricsToken) { + throw new UnauthorizedException("Invalid or missing metrics bearer token."); + } + + return true; + } +} diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts index 861cdfe..1476358 100644 --- a/src/metrics/metrics.controller.ts +++ b/src/metrics/metrics.controller.ts @@ -1,13 +1,30 @@ -import { Controller, Get, Header, Inject } from "@nestjs/common"; +import { Controller, Get, Header, Inject, UseGuards } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; import { MetricsService } from "./metrics.service"; +import { MetricsTokenGuard } from "./metrics-token.guard"; @ApiTags("metrics") @Controller("metrics") export class MetricsController { constructor(@Inject(MetricsService) private readonly metricsService: MetricsService) {} + /** + * Prometheus scrape endpoint. + * + * Access is controlled by MetricsTokenGuard: + * - With METRICS_TOKEN set: require `Authorization: Bearer `. + * - Without METRICS_TOKEN in non-production: open (local dev / test). + * - Without METRICS_TOKEN in production: always 401 (fail closed). + * + * Configure your Prometheus scrape job with: + * authorization: + * type: Bearer + * credentials: + * + * Closes #298. + */ @Get() + @UseGuards(MetricsTokenGuard) @Header("Content-Type", "text/plain; charset=utf-8") async index(): Promise { return this.metricsService.metrics();