diff --git a/.nvmrc b/.nvmrc index eacd481..34adfb4 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v26.5.0 +v26.5.1 diff --git a/Dockerfile b/Dockerfile index 0b58105..ba79e47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 -ARG NODE_VERSION=26.5.0 -ARG ALPINE_VERSION=3.23 +ARG NODE_VERSION=26.5.1 +ARG ALPINE_VERSION=3.24 FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS build @@ -28,8 +28,9 @@ RUN pnpm lint \ && pnpm build \ && pnpm prune --prod --ignore-scripts -FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS production +FROM alpine:${ALPINE_VERSION} AS production +ARG NODE_VERSION ARG RESET_DB_ARG=false ARG SEED_DATA_ARG="" @@ -40,18 +41,18 @@ ENV SEED_DATA=$SEED_DATA_ARG WORKDIR /app RUN apk upgrade --no-cache \ - && apk add --no-cache bash openssl \ - && rm -rf /usr/local/lib/node_modules/npm \ - && rm -f /usr/local/bin/npm /usr/local/bin/npx - -COPY --from=build --chown=node:node /app/dist ./dist -COPY --from=build --chown=node:node /app/node_modules ./node_modules -COPY --from=build --chown=node:node /app/package.json ./package.json -COPY --from=build --chown=node:node /app/prisma ./prisma -COPY --from=build --chown=node:node /app/prisma.config.ts ./prisma.config.ts -COPY --from=build --chown=node:node --chmod=755 /app/appStartUp.sh ./appStartUp.sh - -USER node + && apk add --no-cache bash nodejs-current="${NODE_VERSION}-r0" openssl \ + && addgroup --system --gid 10001 app \ + && adduser --system --disabled-password --no-create-home --uid 10001 --ingroup app app + +COPY --from=build --chown=app:app /app/dist ./dist +COPY --from=build --chown=app:app /app/node_modules ./node_modules +COPY --from=build --chown=app:app /app/package.json ./package.json +COPY --from=build --chown=app:app /app/prisma ./prisma +COPY --from=build --chown=app:app /app/prisma.config.ts ./prisma.config.ts +COPY --from=build --chown=app:app --chmod=755 /app/appStartUp.sh ./appStartUp.sh + +USER app EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ diff --git a/README.md b/README.md index 96f7eb2..2400b16 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ NestJS drop-in replacement for `tc-project-service`, serving the Topcoder platform at `/v6/projects`. [![CircleCI](https://img.shields.io/badge/CircleCI-build%20status-informational?logo=circleci)](https://circleci.com/) -![Node](https://img.shields.io/badge/node-v26.5.0-339933?logo=node.js&logoColor=white) +![Node](https://img.shields.io/badge/node-v26.5.1-339933?logo=node.js&logoColor=white) ![pnpm](https://img.shields.io/badge/pnpm-10.28.2-F69220?logo=pnpm&logoColor=white) ![Audit](https://img.shields.io/badge/production%20audit-0%20findings-brightgreen) @@ -370,7 +370,7 @@ Reference source: `.env.example`. ### Prerequisites -- Node.js `v26.5.0` (`nvm use` in this project folder) +- Node.js `v26.5.1` (`nvm use` in this project folder) - pnpm `10.28.2` - PostgreSQL @@ -437,8 +437,10 @@ Must pass before every commit per `AGENTS.md`. ## Deployment - CI/CD: CircleCI -> AWS ECS Fargate. -- The multi-stage Docker image builds with Node 26.5.0 and copies only compiled - output, production dependencies, and Prisma migration assets into its runtime. +- The multi-stage Docker image builds with Node 26.5.1 and copies only compiled + output, production dependencies, and Prisma migration assets into its Alpine + 3.24 runtime. The runtime installs the dynamically linked Alpine Node package + and does not contain npm or other package tooling. - Container startup invokes the local Prisma CLI to deploy migrations before replacing the shell process with `node dist/src/main`. - Blue-green rollout strategy is documented in `docs/MIGRATION_RUNBOOK.md`. @@ -458,6 +460,7 @@ Open findings are tracked inline with `TODO (security)` comments in source. | `src/main.ts` | CORS returns `'*'` for requests with no `Origin` header | Low | Open - consider returning `false` for server-to-server calls | | `src/main.ts` | Swagger UI publicly accessible with no auth in production | Medium | Open - restrict by IP or add HTTP Basic auth, or gate behind env flag | | `src/main.ts` | Duplicate Swagger mount at `/v6/projects-api-docs` | Low (quality) | Open - consolidate to single path | +| Event publication logging | Environment-derived Kafka topics and raw client errors could reach clear-text logs | High | Resolved - log fixed operation markers and allowlisted error categories only | | `docs/DEPENDENCIES.md` | GitHub-sourced Topcoder packages do not have a registry release stream | Low | Mitigated with immutable commit pins; external API dependencies install only their generated Prisma-client subdirectories | ## Dependency Status @@ -465,7 +468,7 @@ Open findings are tracked inline with `TODO (security)` comments in source. Summary from `docs/DEPENDENCIES.md`: - Production audit: no known vulnerabilities. -- Node 26.5.0, NestJS 11.1.28, Prisma 7.9.0, Axios 1.18.1, +- Node 26.5.1, NestJS 11.1.28, Prisma 7.9.0, Axios 1.18.1, Lodash 4.18.1, qs 6.15.3, and UUID 14.0.1 are locked in the security candidate. - Security overrides for affected transitives are maintained in diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 0739d33..84c0f0c 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -8,10 +8,10 @@ lineage. Toolchain used for this verification cycle: -- Node: `v26.5.0` +- Node: `v26.5.1` - pnpm: `10.28.2` - Prisma CLI, client, and PostgreSQL adapter: `7.9.0` -- Verification date: `2026-07-22` +- Verification date: `2026-09-01` Run `nvm use` from this project directory before each Node or pnpm command. The standard verification commands are: @@ -35,7 +35,7 @@ The remediated direct dependency set includes: | Package group | Version | | --- | --- | -| Node.js | 26.5.0 | +| Node.js | 26.5.1 | | NestJS common, core, platform, and testing | 11.1.28 | | NestJS Swagger | 11.4.6 | | Prisma CLI, client, and PostgreSQL adapter | 7.9.0 | @@ -49,13 +49,16 @@ The remediated direct dependency set includes: Body Parser, Fast URI, Fast XML Parser, Hono's Node server, Multer, Path-to-RegExp, file-type, form-data, js-yaml, brace-expansion, Handlebars, Joi, Piscina, UUID, archive utilities, and related packages. The generated lockfile -is the authoritative record of their resolved versions. +is the authoritative record of their resolved versions. The September 2026 +security refresh resolves `brace-expansion` 5.0.9, `deepmerge-ts` 8.0.0, +`fast-uri` 4.1.2, `find-my-way` 9.7.0, `js-yaml` 3.15.1/4.3.1/5.2.2, and +`valibot` 1.4.2. Prisma 7.9.0 currently prints an upstream support-list warning under Node 26. The four committed external generated clients also retain their existing Prisma 6.19.x runtimes. Client generation, lint, build, migrations, the primary health query, and explicit connection queries through all four external clients are -verified with Node 26.5.0. Keep this compatibility point in deployment QA until +verified with Node 26.5.1. Keep this compatibility point in deployment QA until the applicable Prisma support messages explicitly include Node 26. ## External Prisma clients @@ -105,8 +108,9 @@ package registry would further reduce reliance on Git-hosted installation. ## Production image -The Dockerfile uses separate build and production stages on Node 26.5.0 with -Alpine 3.23. The production stage contains only: +The Dockerfile uses Node 26.5.1 for the build stage and installs Alpine's +dynamically linked Node 26.5.1 package into an Alpine 3.24 production stage. +The production stage contains only: - compiled application output; - production dependencies; @@ -123,11 +127,13 @@ Update this table whenever dependency or image contents change. | Command | Result | | --- | --- | -| `pnpm install --frozen-lockfile` | Passed; Prisma 7.9.0 client generated | +| `pnpm install --frozen-lockfile` | Passed in the production-image build; Prisma 7.9.0 client generated | | `pnpm audit` | Passed: 0 critical, high, moderate, low, or informational advisories | | `pnpm lint` | Passed | | `pnpm build` | Passed | -| `pnpm test --runInBand` | 46 of 57 suites and 360 of 375 tests passed; 10 existing event-publish mock expectations and 5 JWT fixture expectations remain stale on `dev` | -| Docker migration and health smoke test | Passed: 3 migrations applied, server remained running, and `/v6/projects/health` returned `{"checksRun":1}` | -| External generated-client query smoke | Passed for challenge, member, resource, and skills clients under Node 26.5.0 | +| Targeted project/logger tests | Passed: 2 suites and 41 tests | +| `pnpm test --runInBand` | 49 of 60 suites and 380 of 395 tests passed; the same 10 existing event-publish mock expectations and 5 JWT fixture expectations remain stale on `dev` | +| Docker build and runtime inspection | Passed: non-root UID/GID 10001, Node 26.5.1, OpenSSL 3.5.8-r0, dynamic system SSL linkage, and no npm executable | +| Docker migration and health smoke test | Not repeated locally because it requires deployment database configuration; the migration entrypoint is unchanged | +| External generated-client query smoke | Not repeated because it requires external database configuration; the generated-client pins are unchanged from the previous passing cycle | | Trivy 0.72.0 Critical/High/Medium image scan | Passed: 0 / 0 / 0 | diff --git a/package.json b/package.json index 4bc2ea3..2657214 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,6 @@ "node": "26.x" }, "volta": { - "node": "26.5.0" + "node": "26.5.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ede4cc4..e15d25c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,10 +15,12 @@ overrides: body-parser@>=2.0.0 <2.3.0: 2.3.0 brace-expansion@>=1.0.0 <2.0.0: 1.1.16 brace-expansion@>=2.0.0 <3.0.0: 2.1.2 - brace-expansion@>=3.0.0 <5.0.7: 5.0.7 + brace-expansion@>=3.0.0 <5.0.9: 5.0.9 + deepmerge-ts@<8.0.0: 8.0.0 defu@<=6.1.4: 6.1.5 effect@<3.20.0: 3.20.0 - fast-uri@>=3.0.0 <3.1.4: 3.1.4 + fast-uri@>=3.0.0 <4.1.2: 4.1.2 + find-my-way@<9.7.0: 9.7.0 fast-xml-parser@<5.7.0: 5.10.1 file-type@>=20.0.0 <21.3.2: 21.3.2 flatted@<3.4.2: 3.4.2 @@ -26,8 +28,9 @@ overrides: form-data@>=4.0.0 <4.0.6: 4.0.6 handlebars@>=4.0.0 <4.7.9: 4.7.9 hono@<4.12.25: 4.12.31 - js-yaml@>=3.0.0 <3.15.0: 3.15.0 - js-yaml@>=4.0.0 <4.1.2: 4.3.0 + js-yaml@>=3.0.0 <3.15.1: 3.15.1 + js-yaml@>=4.0.0 <4.3.1: 4.3.1 + js-yaml@>=5.0.0 <5.2.2: 5.2.2 joi@>=18.0.0 <18.2.1: 18.2.3 jws: '>=3.2.3 <4.0.0 || >=4.0.1' lodash@<4.18.1: 4.18.1 @@ -40,6 +43,7 @@ overrides: qs@<6.15.2: 6.15.3 terser-webpack-plugin@>=5.0.0 <5.6.1: 5.6.1 uuid@<11.1.1: 11.1.1 + valibot@<1.4.2: 1.4.2 yauzl@3.2.0: 3.2.1 patchedDependencies: @@ -2008,9 +2012,9 @@ packages: bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -2364,8 +2368,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + deepmerge-ts@8.0.0: + resolution: {integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==} engines: {node: '>=16.0.0'} deepmerge@4.3.1: @@ -2658,8 +2662,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@4.1.2: + resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2707,8 +2711,8 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-my-way@9.6.0: - resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} find-up@4.1.0: @@ -3209,16 +3213,16 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - js-yaml@5.2.1: - resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + js-yaml@5.2.2: + resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} hasBin: true jsesc@3.1.0: @@ -4460,8 +4464,8 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -5097,7 +5101,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 10.2.4 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5284,7 +5288,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.0 + js-yaml: 3.15.1 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} @@ -5677,7 +5681,7 @@ snapshots: '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) - js-yaml: 5.2.1 + js-yaml: 5.2.2 lodash: 4.18.1 path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 @@ -5735,7 +5739,7 @@ snapshots: '@prisma/config@7.9.0': dependencies: c12: 3.3.4 - deepmerge-ts: 7.1.5 + deepmerge-ts: 8.0.0 effect: 3.20.0 empathic: 2.0.0 transitivePeerDependencies: @@ -5753,14 +5757,14 @@ snapshots: '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.11 - find-my-way: 9.6.0 + find-my-way: 9.7.0 foreground-child: 3.3.1 get-port-please: 3.2.0 pathe: 2.0.3 proper-lockfile: 4.1.2 remeda: 2.33.4 std-env: 3.10.0 - valibot: 1.2.0(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) zeptomatch: 2.1.0 transitivePeerDependencies: - typescript @@ -6604,7 +6608,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 4.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6791,7 +6795,7 @@ snapshots: bowser@2.13.1: {} - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.3 @@ -7029,7 +7033,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -7120,7 +7124,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@8.0.0: {} deepmerge@4.3.1: {} @@ -7430,7 +7434,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.4: {} + fast-uri@4.1.2: {} fastq@1.20.1: dependencies: @@ -7489,7 +7493,7 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@9.6.0: + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -8183,16 +8187,16 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 - js-yaml@5.2.1: + js-yaml@5.2.2: dependencies: argparse: 2.0.1 @@ -8411,7 +8415,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -9334,7 +9338,7 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - valibot@1.2.0(typescript@5.9.3): + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68c78f9..7cc5bc9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,10 +24,12 @@ overrides: 'body-parser@>=2.0.0 <2.3.0': 2.3.0 'brace-expansion@>=1.0.0 <2.0.0': 1.1.16 'brace-expansion@>=2.0.0 <3.0.0': 2.1.2 - 'brace-expansion@>=3.0.0 <5.0.7': 5.0.7 + 'brace-expansion@>=3.0.0 <5.0.9': 5.0.9 + 'deepmerge-ts@<8.0.0': 8.0.0 'defu@<=6.1.4': 6.1.5 'effect@<3.20.0': 3.20.0 - 'fast-uri@>=3.0.0 <3.1.4': 3.1.4 + 'fast-uri@>=3.0.0 <4.1.2': 4.1.2 + 'find-my-way@<9.7.0': 9.7.0 'fast-xml-parser@<5.7.0': 5.10.1 'file-type@>=20.0.0 <21.3.2': 21.3.2 'flatted@<3.4.2': 3.4.2 @@ -35,8 +37,9 @@ overrides: 'form-data@>=4.0.0 <4.0.6': 4.0.6 'handlebars@>=4.0.0 <4.7.9': 4.7.9 'hono@<4.12.25': 4.12.31 - 'js-yaml@>=3.0.0 <3.15.0': 3.15.0 - 'js-yaml@>=4.0.0 <4.1.2': 4.3.0 + 'js-yaml@>=3.0.0 <3.15.1': 3.15.1 + 'js-yaml@>=4.0.0 <4.3.1': 4.3.1 + 'js-yaml@>=5.0.0 <5.2.2': 5.2.2 'joi@>=18.0.0 <18.2.1': 18.2.3 jws: '>=3.2.3 <4.0.0 || >=4.0.1' 'lodash@<4.18.1': 4.18.1 @@ -49,6 +52,7 @@ overrides: 'qs@<6.15.2': 6.15.3 'terser-webpack-plugin@>=5.0.0 <5.6.1': 5.6.1 'uuid@<11.1.1': 11.1.1 + 'valibot@<1.4.2': 1.4.2 'yauzl@3.2.0': 3.2.1 patchedDependencies: diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 993f7bc..73822bf 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -2313,34 +2313,30 @@ export class ProjectService { /** * Fire-and-forget event publication wrapper. * - * Logs publication failures and intentionally does not rethrow. + * Logs a non-sensitive publication failure marker and intentionally does not + * rethrow. Configured topic values and rejected payloads are not logged. * * @param topic Kafka topic name. * @param payload Event payload. */ private publishEvent(topic: string, payload: unknown): void { - void publishProjectEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); + void publishProjectEvent(topic, payload).catch(() => { + this.logger.error('Failed to publish project event.'); }); } /** * Fire-and-forget raw event publication wrapper (no resource envelope). * - * Logs publication failures and intentionally does not rethrow. + * Logs a non-sensitive publication failure marker and intentionally does not + * rethrow. Configured topic values and rejected payloads are not logged. * * @param topic Kafka topic name. * @param payload Raw event payload. */ private publishRawEvent(topic: string, payload: unknown): void { - void publishRawBusEvent(topic, payload).catch((error) => { - this.logger.error( - `Failed to publish raw event topic=${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); + void publishRawBusEvent(topic, payload).catch(() => { + this.logger.error('Failed to publish raw project event.'); }); } diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index e92fc4f..7e58335 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -78,7 +78,7 @@ export class EventBusService { // TODO (security): The 'topic' parameter is not validated. A caller passing an untrusted or user-supplied topic string could publish to unintended Kafka topics. Validate against an allowlist of known topics. if (!this.client) { this.logger.error( - `Event bus client unavailable for topic ${topic}. initReason=${this.clientInitReason}. configStatus=${this.serializeConfigStatus(this.buildConfigStatus())}`, + `Event bus client unavailable. initReason=${this.clientInitReason}. configStatus=${this.serializeConfigStatus(this.buildConfigStatus())}`, ); throw new ServiceUnavailableException( 'Event bus client is not configured.', @@ -93,11 +93,8 @@ export class EventBusService { 'mime-type': 'application/json', payload, }); - } catch (error) { - this.logger.error( - `Failed to publish event to topic ${topic}: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, - ); + } catch { + this.logger.error('Event bus publish failed.'); throw new InternalServerErrorException( 'Failed to publish event to event bus.', ); @@ -126,19 +123,17 @@ export class EventBusService { return null; } - const missingAuthEnv = this.getMissingAuthEnv(configStatus); - if (missingAuthEnv.length > 0) { - this.clientInitReason = `missing-auth-env:${missingAuthEnv.join(',')}`; + if (this.hasMissingAuthConfig(configStatus)) { + this.clientInitReason = 'missing-auth-env'; this.logger.warn( - `Missing ${missingAuthEnv.join(', ')}. Event publishing disabled. configStatus=${this.serializeConfigStatus(configStatus)}`, + `Event bus authentication configuration is incomplete. Event publishing disabled. configStatus=${this.serializeConfigStatus(configStatus)}`, ); return null; } - const missingRequiredEnv = this.getMissingRequiredEnv(configStatus); - if (missingRequiredEnv.length > 0) { + if (this.hasMissingRequiredConfig(configStatus)) { this.logger.warn( - `Event bus config has empty required values: ${missingRequiredEnv.join(', ')}. Initialization may fail. configStatus=${this.serializeConfigStatus(configStatus)}`, + `Event bus configuration has empty required values. Initialization may fail. configStatus=${this.serializeConfigStatus(configStatus)}`, ); } @@ -156,12 +151,10 @@ export class EventBusService { }); this.clientInitReason = 'initialized'; return client; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.clientInitReason = `client-init-failed:${errorMessage}`; + } catch { + this.clientInitReason = 'client-init-failed'; this.logger.warn( - `Failed to initialize event bus client: ${errorMessage}. configStatus=${this.serializeConfigStatus(configStatus)}`, + `Failed to initialize event bus client. configStatus=${this.serializeConfigStatus(configStatus)}`, ); return null; } @@ -184,17 +177,27 @@ export class EventBusService { : 'empty'; } - private getMissingAuthEnv(status: EventBusConfigStatus): string[] { - const authKeys: Array = [ - 'AUTH0_URL', - 'AUTH0_AUDIENCE', - ]; - - return authKeys.filter((key) => status[key] === 'empty'); + /** + * Determines whether required authentication settings are absent. + * + * The result is used for diagnostics without logging environment-derived + * values or dynamically selected configuration keys. + * + * @param {EventBusConfigStatus} status Sanitized set/empty configuration map. + * @returns {boolean} True when an Auth0 URL or audience is missing. + */ + private hasMissingAuthConfig(status: EventBusConfigStatus): boolean { + return status.AUTH0_URL === 'empty' || status.AUTH0_AUDIENCE === 'empty'; } - private getMissingRequiredEnv(status: EventBusConfigStatus): string[] { - return EVENT_BUS_REQUIRED_ENV_KEYS.filter((key) => status[key] === 'empty'); + /** + * Determines whether any required event-bus setting is absent. + * + * @param {EventBusConfigStatus} status Sanitized set/empty configuration map. + * @returns {boolean} True when at least one required setting is missing. + */ + private hasMissingRequiredConfig(status: EventBusConfigStatus): boolean { + return EVENT_BUS_REQUIRED_ENV_KEYS.some((key) => status[key] === 'empty'); } private serializeConfigStatus(status: EventBusConfigStatus): string { diff --git a/src/shared/utils/event.utils.ts b/src/shared/utils/event.utils.ts index 769fad9..a6eb4d9 100644 --- a/src/shared/utils/event.utils.ts +++ b/src/shared/utils/event.utils.ts @@ -95,24 +95,48 @@ function buildBusApiConfig(): Record { } /** - * Converts unknown errors into safe log messages. + * Reads an error message solely for retry classification. + * + * The returned value must not be logged because upstream clients can include + * connection details or credentials in exception text. + * + * @param {unknown} error Rejected value to inspect. + * @returns {string} Raw message used only by local classification logic. */ -function toErrorMessage(error: unknown): string { +function getErrorMessageForClassification(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** - * Extracts error stack when available. + * Converts an unknown failure into a fixed, non-sensitive diagnostic category. + * + * @param {unknown} error Rejected value to classify. + * @returns {string} An allowlisted failure category safe for log output. */ -function toErrorStack(error: unknown): string | undefined { - return error instanceof Error ? error.stack : undefined; +function classifyError(error: unknown): string { + if (isTransientError(error)) { + return 'transient-network'; + } + + if (error instanceof TypeError) { + return 'type-error'; + } + + if (error instanceof Error) { + return 'application-error'; + } + + return 'non-error-rejection'; } /** * Classifies transient network/socket failures for retry logic. + * + * @param {unknown} error Rejected value to inspect. + * @returns {boolean} True when the error is likely transient and retryable. */ function isTransientError(error: unknown): boolean { - const message = toErrorMessage(error); + const message = getErrorMessageForClassification(error); const normalizedMessage = message.toLowerCase(); const codeValue = error && typeof error === 'object' && 'code' in error @@ -227,8 +251,7 @@ export async function getBusApiClient(): Promise { attempt < MAX_RETRY_ATTEMPTS && isTransientError(error); logger.error( - `Failed to initialize BUS API client attempt=${attempt + 1}/${MAX_RETRY_ATTEMPTS + 1}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to initialize BUS API client attempt=${attempt + 1}/${MAX_RETRY_ATTEMPTS + 1} errorCategory=${classifyError(error)}.`, ); if (!shouldRetry) { @@ -241,7 +264,7 @@ export async function getBusApiClient(): Promise { } throw new Error( - `Unable to initialize BUS API client: ${toErrorMessage(lastError)}`, + `Unable to initialize BUS API client. errorCategory=${classifyError(lastError)}.`, ); } @@ -275,7 +298,7 @@ async function postEventWithRetry( ): Promise { if (isCircuitOpen()) { logger.warn( - `Skipping event publish because circuit is open operation=${operation} topic=${event.topic}.`, + `Skipping event publish because circuit is open operation=${operation}.`, ); return; } @@ -301,8 +324,7 @@ async function postEventWithRetry( attempt < MAX_RETRY_ATTEMPTS && isTransientError(error); logger.error( - `Failed to publish ${operation} topic=${event.topic} attempt=${attempt + 1}/${MAX_RETRY_ATTEMPTS + 1} payloadSize=${payloadSize}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish ${operation} attempt=${attempt + 1}/${MAX_RETRY_ATTEMPTS + 1} payloadSize=${payloadSize} errorCategory=${classifyError(error)}.`, ); if (!shouldRetry) { @@ -316,7 +338,7 @@ async function postEventWithRetry( registerFailure(); logger.warn( - `Event publish abandoned operation=${operation} topic=${event.topic} payloadSize=${payloadSize}: ${toErrorMessage(lastError)}`, + `Event publish abandoned operation=${operation} payloadSize=${payloadSize} errorCategory=${classifyError(lastError)}.`, ); } @@ -466,8 +488,7 @@ export async function publishProjectEvent( ); } catch (error) { logger.error( - `Failed to publish project event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish project event. errorCategory=${classifyError(error)}.`, ); } } @@ -488,8 +509,7 @@ export async function publishMemberEvent( ); } catch (error) { logger.error( - `Failed to publish member event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish member event. errorCategory=${classifyError(error)}.`, ); } } @@ -504,8 +524,7 @@ export function publishMemberEventSafely( ): void { void publishMemberEvent(topic, payload).catch((error) => { errorLogger.error( - `Failed to publish member event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish member event. errorCategory=${classifyError(error)}.`, ); }); } @@ -520,8 +539,7 @@ export function publishInviteEventSafely( ): void { void publishInviteEvent(topic, payload).catch((error) => { errorLogger.error( - `Failed to publish invite event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish invite event. errorCategory=${classifyError(error)}.`, ); }); } @@ -542,8 +560,7 @@ export async function publishInviteEvent( ); } catch (error) { logger.error( - `Failed to publish invite event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish invite event. errorCategory=${classifyError(error)}.`, ); } } @@ -564,8 +581,7 @@ export async function publishAttachmentEvent( ); } catch (error) { logger.error( - `Failed to publish attachment event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish attachment event. errorCategory=${classifyError(error)}.`, ); } } @@ -586,8 +602,7 @@ export async function publishPhaseEvent( ); } catch (error) { logger.error( - `Failed to publish phase event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish phase event. errorCategory=${classifyError(error)}.`, ); } } @@ -608,8 +623,7 @@ export async function publishPhaseProductEvent( ); } catch (error) { logger.error( - `Failed to publish phase-product event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish phase-product event. errorCategory=${classifyError(error)}.`, ); } } @@ -630,8 +644,7 @@ export async function publishTimelineEvent( ); } catch (error) { logger.error( - `Failed to publish timeline event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish timeline event. errorCategory=${classifyError(error)}.`, ); } } @@ -652,8 +665,7 @@ export async function publishMilestoneEvent( ); } catch (error) { logger.error( - `Failed to publish milestone event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish milestone event. errorCategory=${classifyError(error)}.`, ); } } @@ -674,8 +686,7 @@ export async function publishWorkstreamEvent( ); } catch (error) { logger.error( - `Failed to publish workstream event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish workstream event. errorCategory=${classifyError(error)}.`, ); } } @@ -696,8 +707,7 @@ export async function publishWorkEvent( ); } catch (error) { logger.error( - `Failed to publish work event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish work event. errorCategory=${classifyError(error)}.`, ); } } @@ -718,8 +728,7 @@ export async function publishWorkItemEvent( ); } catch (error) { logger.error( - `Failed to publish workitem event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish workitem event. errorCategory=${classifyError(error)}.`, ); } } @@ -740,8 +749,7 @@ export async function publishSettingEvent( ); } catch (error) { logger.error( - `Failed to publish setting event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish setting event. errorCategory=${classifyError(error)}.`, ); } } @@ -762,8 +770,7 @@ export async function publishNotificationEvent( ); } catch (error) { logger.error( - `Failed to publish notification event topic=${topic}: ${toErrorMessage(error)}`, - toErrorStack(error), + `Failed to publish notification event. errorCategory=${classifyError(error)}.`, ); } }