/** * TASK-0109 §Backend — the test-only debug events endpoint. * * `GET /api/v1/debug/events?name=form.submitted` returns the most recent * matching envelopes from the Redis stream `factory:events`, so the E2E * suite can assert stream events end-to-end (the ROADMAP exit criterion) * without standing up a worker. * * Compile/runtime gates (spec §Security — "compiled behind NODE_ENV=test, * not merely guarded at runtime"): * 1. `NODE_ENV !== "test"` → the route returns 404 for EVERY caller * (production builds never expose the stream); * 2. `X-Debug-Token` must equal the env `TEST_DEBUG_TOKEN` (a random * per-run secret in the integration app; empty in production — an * empty expected token NEVER matches, so the endpoint is closed). * The path is auth-guard-whitelisted (it is not a public product surface — * it only ever resolves to 404/401 in non-test builds). */ import { Controller, Get, Inject, Query, Req } from "@nestjs/common"; import type { Request } from "express"; import { ApiError } from "../../common/errors.js"; import { RedisService } from "../../common/redis.service.js"; import { loadEnv } from "../../config/env.js"; import { EVENTS_STREAM } from "../../constants.js"; @Controller() export class DebugController { constructor(@Inject(RedisService) private readonly redis: RedisService) {} @Get("debug/events") async events( @Query("name") name: string | undefined, @Query("limit") limit: string | undefined, @Req() req: Request ) { // Gate 1: test builds only (404 elsewhere — no existence leak). if (process.env.NODE_ENV !== "test") throw ApiError.notFound("Not found."); // Gate 2: the debug token (empty expected token never matches). const expected = loadEnv().TEST_DEBUG_TOKEN; const given = String(req.headers["x-debug-token"] ?? ""); if (!expected || given !== expected) { throw ApiError.unauthorized("Invalid debug token.", [{ reason: "debug_token_invalid" }]); } const count = Math.min(Math.max(Number(limit ?? 50) || 50, 1), 200); // ioredis signature: xrange(key, start, end, "COUNT", n) — COUNT is // POSITIONAL (not the { COUNT } object of node-redis v4). It returns // flat [id, [k, v, k, v, …]] pairs (NOT a per-entry map), so each entry // is folded into a map first (mirrors the worker's readLoop conversion). const raw = await this.redis .getRedis() .xrange(EVENTS_STREAM, "-", "+", "COUNT", 1000) .catch((): [string, string[]][] => []); const out: { event: string; payload: unknown; ts: string }[] = []; for (const [, fields] of raw) { const map: Record = {}; for (let i = 0; i < fields.length; i += 2) map[fields[i]!] = fields[i + 1]!; if (name && map["event"] !== name) continue; let payload: unknown = {}; try { payload = map["payload"] ? JSON.parse(map["payload"]) : {}; } catch { payload = map["payload"] ?? {}; } out.push({ event: map["event"] ?? "", payload, ts: map["ts"] ?? "" }); if (out.length >= count) break; } return { items: out }; } }