From f8ce7c55d7eaca3c99e1149a7e237a7beef69ef3 Mon Sep 17 00:00:00 2001 From: Teep1 Date: Wed, 2 Jul 2025 11:34:23 +0100 Subject: [PATCH 01/15] Update Block Numbers --- scripts/snapshots/data/blockNumbers-ADs.js | 4 +++- scripts/snapshots/data/blockNumbers-DAIF.js | 4 +++- scripts/snapshots/data/blockNumbers-DEWIZ.js | 4 +++- scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js | 4 +++- scripts/snapshots/data/blockNumbers-DRAFT.js | 4 +++- scripts/snapshots/data/blockNumbers-IS.js | 4 +++- scripts/snapshots/data/blockNumbers-JETSTREAM.js | 4 +++- scripts/snapshots/data/blockNumbers-PH.js | 4 +++- scripts/snapshots/data/blockNumbers-TECH-EA.js | 4 +++- 9 files changed, 27 insertions(+), 9 deletions(-) diff --git a/scripts/snapshots/data/blockNumbers-ADs.js b/scripts/snapshots/data/blockNumbers-ADs.js index 74972d8..dab98f7 100644 --- a/scripts/snapshots/data/blockNumbers-ADs.js +++ b/scripts/snapshots/data/blockNumbers-ADs.js @@ -25,5 +25,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22675856, + "2025/07": 22895856 }; diff --git a/scripts/snapshots/data/blockNumbers-DAIF.js b/scripts/snapshots/data/blockNumbers-DAIF.js index ab14dfd..f1f5038 100644 --- a/scripts/snapshots/data/blockNumbers-DAIF.js +++ b/scripts/snapshots/data/blockNumbers-DAIF.js @@ -44,5 +44,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-DEWIZ.js b/scripts/snapshots/data/blockNumbers-DEWIZ.js index 45d971f..3cf306c 100644 --- a/scripts/snapshots/data/blockNumbers-DEWIZ.js +++ b/scripts/snapshots/data/blockNumbers-DEWIZ.js @@ -23,5 +23,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js b/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js index c960583..f64f464 100644 --- a/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js +++ b/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js @@ -36,5 +36,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-DRAFT.js b/scripts/snapshots/data/blockNumbers-DRAFT.js index 9f666a8..e7f2dbb 100644 --- a/scripts/snapshots/data/blockNumbers-DRAFT.js +++ b/scripts/snapshots/data/blockNumbers-DRAFT.js @@ -51,5 +51,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-IS.js b/scripts/snapshots/data/blockNumbers-IS.js index b4625ee..6e1f2d8 100644 --- a/scripts/snapshots/data/blockNumbers-IS.js +++ b/scripts/snapshots/data/blockNumbers-IS.js @@ -29,5 +29,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191, }; diff --git a/scripts/snapshots/data/blockNumbers-JETSTREAM.js b/scripts/snapshots/data/blockNumbers-JETSTREAM.js index f954357..f6d17c9 100644 --- a/scripts/snapshots/data/blockNumbers-JETSTREAM.js +++ b/scripts/snapshots/data/blockNumbers-JETSTREAM.js @@ -22,5 +22,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-PH.js b/scripts/snapshots/data/blockNumbers-PH.js index c06529a..ebdd5d1 100644 --- a/scripts/snapshots/data/blockNumbers-PH.js +++ b/scripts/snapshots/data/blockNumbers-PH.js @@ -13,5 +13,7 @@ export default { "2025/02": 21969838, "2025/03": 22181687, "2025/04": 22365931, - "2025/05": 22455856 + "2025/05": 22597645, + "2025/06": 22816918, + "2025/07": 23036191, }; diff --git a/scripts/snapshots/data/blockNumbers-TECH-EA.js b/scripts/snapshots/data/blockNumbers-TECH-EA.js index 9292725..32a5c15 100644 --- a/scripts/snapshots/data/blockNumbers-TECH-EA.js +++ b/scripts/snapshots/data/blockNumbers-TECH-EA.js @@ -20,5 +20,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191, }; From 0fc070362c0cbf6ec934f01d324d4af6ae529e36 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Feb 2026 19:50:24 +0100 Subject: [PATCH 02/15] Add Docker entrypoint and GitHub Actions workflow for k8s deployment --- .github/workflows/deploy.yml | 238 +++++++++++++++++++++++++++++++++++ Dockerfile | 13 +- docker/entrypoint.sh | 12 ++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 docker/entrypoint.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c12e495 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,238 @@ +name: Build and Deploy + +on: + push: + branches: + - main + - staging + - dev + + workflow_dispatch: + inputs: + channel: + description: "Release channel" + required: true + type: choice + options: + - dev + - staging + - main + default: "staging" + dry-run: + description: "Dry run (skip publishing)" + required: false + type: boolean + default: false + +env: + NODE_VERSION: "22" + PNPM_VERSION: "9" + DOCKER_REGISTRY: cr.vetra.io + PROJECT_NAME: ecosystem-api + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.params.outputs.channel }} + branch: ${{ steps.params.outputs.branch }} + dry_run: ${{ steps.params.outputs.dry_run }} + version: ${{ steps.params.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine parameters + id: params + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + CHANNEL="${{ inputs.channel }}" + DRY_RUN="${{ inputs.dry-run }}" + else + # Determine channel from branch + BRANCH="${{ github.ref_name }}" + case "$BRANCH" in + main) CHANNEL="main" ;; + staging) CHANNEL="staging" ;; + dev) CHANNEL="dev" ;; + *) CHANNEL="dev" ;; + esac + DRY_RUN="false" + fi + + BRANCH="${{ github.ref_name }}" + VERSION=$(node -p "require('./package.json').version") + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + + # Create version tag + if [ "$CHANNEL" = "main" ]; then + VERSION_TAG="v${VERSION}" + else + VERSION_TAG="v${VERSION}-${CHANNEL}.${SHORT_SHA}" + fi + + echo "channel=$CHANNEL" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "dry_run=$DRY_RUN" >> $GITHUB_OUTPUT + echo "version=$VERSION_TAG" >> $GITHUB_OUTPUT + + echo "Channel: $CHANNEL" + echo "Branch: $BRANCH" + echo "Version: $VERSION_TAG" + echo "Dry Run: $DRY_RUN" + + build-docker: + name: Build Docker Image + needs: [prepare] + if: needs.prepare.outputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Ensure Docker project exists + run: | + PROJECT_NAME="${{ env.PROJECT_NAME }}" + + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects?name=${PROJECT_NAME}") + + if [ "$STATUS" = "200" ]; then + EXISTS=$(curl -s \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects?name=${PROJECT_NAME}" | \ + jq -r ".[] | select(.name==\"${PROJECT_NAME}\") | .name") + + if [ "$EXISTS" = "$PROJECT_NAME" ]; then + echo "Project ${PROJECT_NAME} already exists" + else + echo "Creating project ${PROJECT_NAME}..." + curl -X POST \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + -H "Content-Type: application/json" \ + -d "{\"project_name\": \"${PROJECT_NAME}\", \"public\": false}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects" + fi + else + echo "Creating project ${PROJECT_NAME}..." + curl -X POST \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + -H "Content-Type: application/json" \ + -d "{\"project_name\": \"${PROJECT_NAME}\", \"public\": false}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects" + fi + + - name: Login to Docker Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Determine image tags + id: tags + run: | + VERSION="${{ needs.prepare.outputs.version }}" + CHANNEL="${{ needs.prepare.outputs.channel }}" + + DOCKER_BASE="${{ env.DOCKER_REGISTRY }}/${{ env.PROJECT_NAME }}/api" + + TAGS="${DOCKER_BASE}:${VERSION}" + + if [ "$CHANNEL" = "main" ]; then + TAGS="${TAGS},${DOCKER_BASE}:latest" + else + TAGS="${TAGS},${DOCKER_BASE}:${CHANNEL}" + fi + + echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "Image tags: $TAGS" + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + + update-k8s: + name: Update K8s Cluster + needs: [prepare, build-docker] + if: | + needs.prepare.outputs.dry_run != 'true' && + (needs.prepare.outputs.channel == 'dev' || needs.prepare.outputs.channel == 'staging') + runs-on: ubuntu-latest + steps: + - name: Checkout k8s-cluster repository + uses: actions/checkout@v4 + with: + repository: powerhouse-inc/powerhouse-k8s-cluster + token: ${{ secrets.K8S_REPO_PAT }} + path: k8s-cluster + + - name: Install yq + uses: mikefarah/yq@v4 + + - name: Update image tags + run: | + CHANNEL="${{ needs.prepare.outputs.channel }}" + VERSION="${{ needs.prepare.outputs.version }}" + VALUES_FILE="k8s-cluster/tenants/${CHANNEL}/ecosystem-api-values.yaml" + + echo "Updating ${VALUES_FILE} with version ${VERSION}" + + yq -i ".ecosystemApi.image.tag = \"${VERSION}\"" "$VALUES_FILE" + + echo "Updated image tag to ${VERSION}" + cat "$VALUES_FILE" + + - name: Commit and push changes + run: | + cd k8s-cluster + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + CHANNEL="${{ needs.prepare.outputs.channel }}" + VERSION="${{ needs.prepare.outputs.version }}" + + git add "tenants/${CHANNEL}/ecosystem-api-values.yaml" + + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "chore(${CHANNEL}): update ecosystem-api image tag to ${VERSION}" + git push + fi + + summary: + name: Release Summary + needs: [prepare, build-docker, update-k8s] + if: always() + runs-on: ubuntu-latest + steps: + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Channel | ${{ needs.prepare.outputs.channel }} |" >> $GITHUB_STEP_SUMMARY + echo "| Branch | ${{ needs.prepare.outputs.branch }} |" >> $GITHUB_STEP_SUMMARY + echo "| Version | ${{ needs.prepare.outputs.version }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dry Run | ${{ needs.prepare.outputs.dry_run }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Image" >> $GITHUB_STEP_SUMMARY + echo "\`${{ env.DOCKER_REGISTRY }}/${{ env.PROJECT_NAME }}/api:${{ needs.prepare.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile b/Dockerfile index 234c6c9..20a26f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,20 @@ RUN tsc -p ./config/tsconfig.json # runner FROM node:22.5-alpine AS runner +RUN apk add --no-cache curl RUN npm install -g knex WORKDIR /app COPY --from=builder /app/ ./ +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh -ENTRYPOINT [ "node" ] +ENV NODE_ENV=production +ENV PORT=4000 + +EXPOSE ${PORT} + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:${PORT}/healthz || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..e10dd98 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Run migrations if not skipped +if [ "$SKIP_DB_MIGRATIONS" != "true" ] && [ -n "$PG_CONNECTION_STRING" ]; then + echo "[entrypoint] Running database migrations..." + knex migrate:latest --knexfile ./knexfile.js + echo "[entrypoint] Migrations completed" +fi + +echo "[entrypoint] Starting ecosystem-api on port ${PORT:-4000}..." +exec node ./build/index.js From 0eab400fc210fa60c82bd790114c2ef320b7f448 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Feb 2026 19:55:28 +0100 Subject: [PATCH 03/15] Fix Dockerfile to use npm instead of pnpm --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 20a26f6..fe6c93b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # builder FROM node:22.5-alpine AS builder -RUN npm install -g typescript pnpm +RUN npm install -g typescript WORKDIR /app -COPY package.json pnpm-lock.yaml knexfile.js ./ -RUN pnpm install +COPY package.json package-lock.json knexfile.js ./ +RUN npm ci COPY . ./ RUN tsc -p ./config/tsconfig.json From ed8e8d884e12d3a4b12e1fbb635499613919fd2f Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Feb 2026 20:00:30 +0100 Subject: [PATCH 04/15] Fix k8s repo name to powerhouse-k8s-hosting --- .github/workflows/deploy.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c12e495..395a89d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -176,12 +176,12 @@ jobs: (needs.prepare.outputs.channel == 'dev' || needs.prepare.outputs.channel == 'staging') runs-on: ubuntu-latest steps: - - name: Checkout k8s-cluster repository + - name: Checkout k8s-hosting repository uses: actions/checkout@v4 with: - repository: powerhouse-inc/powerhouse-k8s-cluster + repository: powerhouse-inc/powerhouse-k8s-hosting token: ${{ secrets.K8S_REPO_PAT }} - path: k8s-cluster + path: k8s-hosting - name: Install yq uses: mikefarah/yq@v4 @@ -190,7 +190,7 @@ jobs: run: | CHANNEL="${{ needs.prepare.outputs.channel }}" VERSION="${{ needs.prepare.outputs.version }}" - VALUES_FILE="k8s-cluster/tenants/${CHANNEL}/ecosystem-api-values.yaml" + VALUES_FILE="k8s-hosting/tenants/${CHANNEL}/ecosystem-api-values.yaml" echo "Updating ${VALUES_FILE} with version ${VERSION}" @@ -201,7 +201,7 @@ jobs: - name: Commit and push changes run: | - cd k8s-cluster + cd k8s-hosting git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" From ef642972680ecb358a630209d8eca37f7f81f5ee Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Feb 2026 20:11:55 +0100 Subject: [PATCH 05/15] Fix values path for standalone helm chart --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 395a89d..cc895e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -194,7 +194,7 @@ jobs: echo "Updating ${VALUES_FILE} with version ${VERSION}" - yq -i ".ecosystemApi.image.tag = \"${VERSION}\"" "$VALUES_FILE" + yq -i ".image.tag = \"${VERSION}\"" "$VALUES_FILE" echo "Updated image tag to ${VERSION}" cat "$VALUES_FILE" From 8ea95e0fc952962acc922bea1e0f10092a1c0099 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Feb 2026 21:20:20 +0100 Subject: [PATCH 06/15] Enable GraphQL introspection --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index c8e040e..6ffa138 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ async function startApolloServer( const server = new ApolloServer({ schema, plugins, + introspection: true, persistedQueries: { cache: new InMemoryLRUCache({ maxSize: 1000, From ad92cfbda91a16473adb0dc90074d42dcd6575fb Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Feb 2026 21:27:24 +0100 Subject: [PATCH 07/15] Enable Apollo Explorer --- src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 6ffa138..2197572 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { ApolloServer } from "@apollo/server"; import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; import express from "express"; import http from "http"; @@ -82,7 +83,11 @@ async function startApolloServer( resolvers: apiModules.resolvers, }); - const plugins = [ApolloServerPluginDrainHttpServer({ httpServer }), responseCachePlugin()]; + const plugins = [ + ApolloServerPluginDrainHttpServer({ httpServer }), + responseCachePlugin(), + ApolloServerPluginLandingPageLocalDefault({ embed: true }), + ]; const server = new ApolloServer({ schema, plugins, From 9fc8e9ebc167b182c5abf136dc0d4747ba410d23 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:01:34 +0100 Subject: [PATCH 08/15] Fix SQL injection in sort direction interpolation Whitelist sortByLastModified to only ASC/DESC before interpolation into orderByRaw() to prevent SQL injection via GraphQL filter input. Also add column name allowlists for getBudgetStatementLineItems and getBudgetStatementComments to prevent dynamic column injection. Co-Authored-By: Claude Opus 4.6 --- src/modules/BudgetStatement/db.ts | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/modules/BudgetStatement/db.ts b/src/modules/BudgetStatement/db.ts index 13096ff..75b3bc4 100644 --- a/src/modules/BudgetStatement/db.ts +++ b/src/modules/BudgetStatement/db.ts @@ -230,17 +230,18 @@ export class BudgetStatementModel { // use other join query if lastModified filter is set if (filter?.filter?.sortByLastModified) { + const sortDir = filter.filter.sortByLastModified.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const subquery = this.knex .select(this.knex.raw("DISTINCT ON ((params->>'budgetStatementId')::integer) params->>'budgetStatementId' as id, created_at")) .from("ChangeTrackingEvents") .whereRaw("params->>'budgetStatementId' IS NOT NULL") - .orderByRaw(`(params->>'budgetStatementId')::integer, created_at ${filter?.filter?.sortByLastModified.toUpperCase()}`); + .orderByRaw(`(params->>'budgetStatementId')::integer, created_at ${sortDir}`); query = this.knex .select("BudgetStatement.*", "cte.created_at as last_modified") .from("BudgetStatement") .leftJoin(subquery.as('cte'), 'BudgetStatement.id', this.knex.raw('cte.id::integer')) - .orderByRaw(`last_modified ${filter?.filter?.sortByLastModified} NULLS LAST, month DESC`); + .orderByRaw(`last_modified ${sortDir} NULLS LAST, month DESC`); } if (filter?.limit !== undefined && filter?.offset !== undefined) { @@ -386,6 +387,13 @@ export class BudgetStatementModel { .select("*") .from("BudgetStatementLineItem") .orderBy("month", "desc"); + const allowedLineItemColumns = ['id', 'budgetStatementWalletId', 'month', 'position', 'group', 'budgetCategory', 'forecast', 'actual', 'comments', 'canonicalBudgetCategory', 'headcountExpense', 'budgetCap', 'payment']; + if (paramName !== undefined && !allowedLineItemColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + if (secondParamName !== undefined && !allowedLineItemColumns.includes(secondParamName)) { + throw new Error(`Invalid column name: ${secondParamName}`); + } if (offset != undefined && limit != undefined) { return baseQuery.limit(limit).offset(offset); } else if ( @@ -394,7 +402,7 @@ export class BudgetStatementModel { secondParamName === undefined && secondParamValue === undefined ) { - return baseQuery.where(`${paramName}`, paramValue); + return baseQuery.where(paramName, paramValue); } else if ( paramName !== undefined && paramValue !== undefined && @@ -402,8 +410,8 @@ export class BudgetStatementModel { secondParamValue !== undefined ) { return baseQuery - .where(`${paramName}`, paramValue) - .andWhere(`${secondParamName}`, secondParamValue); + .where(paramName, paramValue) + .andWhere(secondParamName, secondParamValue); } else { return baseQuery; } @@ -474,6 +482,13 @@ export class BudgetStatementModel { secondParamName?: string | undefined, secondParamValue?: string | number | boolean | undefined, ): Promise { + const allowedCommentColumns = ['id', 'budgetStatementId', 'authorId', 'timestamp', 'comment', 'status']; + if (paramName !== undefined && !allowedCommentColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + if (secondParamName !== undefined && !allowedCommentColumns.includes(secondParamName)) { + throw new Error(`Invalid column name: ${secondParamName}`); + } const baseQuery = this.knex .select("*") .from("BudgetStatementComment") @@ -484,7 +499,7 @@ export class BudgetStatementModel { secondParamName === undefined && secondParamValue === undefined ) { - return baseQuery.where(`${paramName}`, paramValue); + return baseQuery.where(paramName, paramValue); } else if ( paramName !== undefined && paramValue !== undefined && @@ -492,8 +507,8 @@ export class BudgetStatementModel { secondParamValue !== undefined ) { return baseQuery - .where(`${paramName}`, paramValue) - .andWhere(`${secondParamName}`, secondParamValue); + .where(paramName, paramValue) + .andWhere(secondParamName, secondParamValue); } else { return baseQuery; } From c35e199fc63ecbee9170bd97293ae4b1169048e9 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:01:39 +0100 Subject: [PATCH 09/15] Fix SQL injection in cache expiration interval Use parameterized Knex raw with make_interval() instead of string concatenation for expirationInMinutes in ResolverCache. Co-Authored-By: Claude Opus 4.6 --- src/modules/BudgetStatement/ResolverCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/BudgetStatement/ResolverCache.ts b/src/modules/BudgetStatement/ResolverCache.ts index 0f13c49..9333bb8 100644 --- a/src/modules/BudgetStatement/ResolverCache.ts +++ b/src/modules/BudgetStatement/ResolverCache.ts @@ -97,7 +97,7 @@ export class ResolverCache { .insert({ hash, expiry: this._knex.raw( - "CURRENT_TIMESTAMP + interval '" + expirationInMinutes + " minutes'", + "CURRENT_TIMESTAMP + make_interval(mins => ?)", [expirationInMinutes], ), data: JSON.stringify(outputGroups), }) From 7560b47e69994b742eebdb679d515d36bac5aa5f Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:01:43 +0100 Subject: [PATCH 10/15] Fix dynamic column name injection in Roadmap queries Add column name allowlist validation for all Roadmap model query methods to prevent SQL injection via user-supplied paramName values. Co-Authored-By: Claude Opus 4.6 --- src/modules/Roadmap/db.ts | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/modules/Roadmap/db.ts b/src/modules/Roadmap/db.ts index a111357..5b56908 100644 --- a/src/modules/Roadmap/db.ts +++ b/src/modules/Roadmap/db.ts @@ -94,13 +94,20 @@ export class RoadmapModel { this.coreUnitModel = coreUnitModel; } + private validateColumn(paramName: string, allowedColumns: string[]): void { + if (!allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + } + async getRoadmaps( paramName?: string | undefined, paramValue?: string | number | boolean | undefined, ): Promise { const baseQuery = this.knex.select("*").from("Roadmap").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'ownerCuId', 'roadmapCode', 'roadmapName', 'comments', 'roadmapStatus', 'strategicInitiative', 'roadmapSummary']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -115,7 +122,8 @@ export class RoadmapModel { .from("RoadmapStakeholder") .orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'stakeholderId', 'roadmapId', 'stakeholderRoleId']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -130,7 +138,8 @@ export class RoadmapModel { .from("StakeholderRole") .orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'stakeholderRoleName']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -142,7 +151,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Stakeholder").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'name', 'stakeholderContributorId', 'stakeholderCuCode']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -154,7 +164,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("RoadmapOutput").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'outputId', 'roadmapId', 'outputTypeId']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -166,7 +177,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Output").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'name', 'outputUrl', 'outputDate']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -178,7 +190,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("OutputType").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'outputType']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -190,7 +203,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Milestone").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'roadmapId', 'taskId']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -202,7 +216,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Task").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'parentId', 'taskName', 'taskStatus', 'ownerStakeholderId', 'startDate', 'target', 'completedPercentage', 'confidenceLevel', 'comments']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -214,7 +229,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Review").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'taskId', 'reviewDate', 'reviewOutcome']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } From e4ad92a060e105639d32f1f3e7ae4251b6ff76dc Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:01:59 +0100 Subject: [PATCH 11/15] Fix dynamic column name injection in Auth queries Add column name allowlist validation for getUser and getUsers methods to prevent SQL injection via user-supplied paramName values. Co-Authored-By: Claude Opus 4.6 --- src/modules/Auth/db.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/modules/Auth/db.ts b/src/modules/Auth/db.ts index af24d7c..7f7a74a 100644 --- a/src/modules/Auth/db.ts +++ b/src/modules/Auth/db.ts @@ -29,7 +29,11 @@ export class AuthModel { paramName: string, paramValue: string | number | boolean, ): Promise { - return this.knex("User").where(`${paramName}`, paramValue); + const allowedColumns = ['id', 'username', 'password', 'active']; + if (!allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + return this.knex("User").where(paramName, paramValue); } async getResourceId(userId: number) { @@ -257,8 +261,12 @@ export class AuthModel { }) .orderBy("User.id", "asc"); if (paramName !== undefined && paramValue !== undefined) { + const allowedColumns = ['id', 'username', 'active']; + if (!allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } return await baseQuery.where( - paramName === "id" ? "User.id" : `${paramName}`, + paramName === "id" ? "User.id" : paramName, paramValue, ); } From 843a0bc99fc7d7678ca815b9064737176d70e356 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:02:03 +0100 Subject: [PATCH 12/15] Fix column injection and JSON.parse crash in ChangeTracking Add column name allowlist for getUserActivity and wrap JSON.parse in getBsEvents with try-catch to prevent crashes on malformed input. Co-Authored-By: Claude Opus 4.6 --- src/modules/ChangeTracking/db.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/modules/ChangeTracking/db.ts b/src/modules/ChangeTracking/db.ts index 5a0c0c5..c13dd48 100644 --- a/src/modules/ChangeTracking/db.ts +++ b/src/modules/ChangeTracking/db.ts @@ -122,10 +122,16 @@ export class ChangeTrackingModel { } getBsEvents(bsId: string) { + let parsedBsId: any; + try { + parsedBsId = JSON.parse(bsId); + } catch { + return this.knex.select("*").from("ChangeTrackingEvents").whereRaw("1 = 0"); + } return this.knex .select("*") .from("ChangeTrackingEvents") - .whereRaw("params->>? = ?", ["budgetStatementId", JSON.parse(bsId)]) + .whereRaw("params->>? = ?", ["budgetStatementId", parsedBsId]) .orderBy("ChangeTrackingEvents.id", "desc"); } @@ -333,6 +339,13 @@ export class ChangeTrackingModel { secondParamName: string | undefined, secondParamValue: string | undefined, ) { + const allowedColumns = ['id', 'userId', 'collection', 'data', 'lastVisit']; + if (paramName !== undefined && !allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + if (secondParamName !== undefined && !allowedColumns.includes(secondParamName)) { + throw new Error(`Invalid column name: ${secondParamName}`); + } if ( paramName === undefined && paramValue === undefined && @@ -342,13 +355,13 @@ export class ChangeTrackingModel { return this.knex("UserActivity").orderBy("id", "desc").limit(1); } else if (secondParamName == undefined && secondParamValue == undefined) { return this.knex("UserActivity") - .where(`${paramName}`, paramValue) + .where(paramName!, paramValue) .orderBy("id", "desc") .limit(1); } else { return this.knex("UserActivity") - .where(`${paramName}`, paramValue) - .andWhere(`${secondParamName}`, secondParamValue) + .where(paramName!, paramValue) + .andWhere(secondParamName!, secondParamValue) .orderBy("id", "desc") .limit(1); } From 20f1718b02f5ddd21f5251bf7c8c069885d5596d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:02:08 +0100 Subject: [PATCH 13/15] Fix LIKE wildcard injection in budget path resolution Escape %, _, and \ characters in user-supplied path before using in LIKE clause to prevent wildcard injection attacks. Co-Authored-By: Claude Opus 4.6 --- src/modules/BudgetStatement/schema/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/BudgetStatement/schema/utils.ts b/src/modules/BudgetStatement/schema/utils.ts index 0c5e939..d00e683 100644 --- a/src/modules/BudgetStatement/schema/utils.ts +++ b/src/modules/BudgetStatement/schema/utils.ts @@ -128,8 +128,8 @@ export const resolveBudgetPath = async (path: string, knex: Knex) => { // Escape single quotes in path // include / at the end to ensure we are matching the full path path = path.endsWith('/') ? path : `${path}/`; - const escapedPath = path.replace(/'/g, "''"); - query = query.where('path', 'like', `${escapedPath}%`); + const escapedPath = path.replace(/[%_\\]/g, '\\$&'); + query = query.whereRaw(`"path" LIKE ? ESCAPE '\\'`, [`${escapedPath}%`]); const result = await query; return result.map((item: any) => item.budgetStatementId); From 471398381f48114fafd207ee17fd6481a0d62f6d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:02:12 +0100 Subject: [PATCH 14/15] Fix timing attack on cache refresh secret comparison Use crypto.timingSafeEqual() instead of !== for secret comparison to prevent timing-based side-channel attacks. Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2197572..e2b64bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin import express from "express"; import http from "http"; +import crypto from "crypto"; import cors from "cors"; import compression from "compression"; @@ -52,7 +53,14 @@ function buildExpressApp() { }); }); app.get('/update-cache', async (_req, res) => { - if (_req.headers['refresh-cache'] !== process.env.REFRESH_CACHE_SECRET) { + const headerVal = _req.headers['refresh-cache']; + const secretVal = process.env.REFRESH_CACHE_SECRET; + if ( + typeof headerVal !== 'string' || + !secretVal || + Buffer.byteLength(headerVal) !== Buffer.byteLength(secretVal) || + !crypto.timingSafeEqual(Buffer.from(headerVal), Buffer.from(secretVal)) + ) { return res.status(401).json({ status: 'error', message: 'Unauthorized', From 2efbbef9c2ca861358ed50f88e438338f43e9173 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Feb 2026 11:22:44 +0100 Subject: [PATCH 15/15] Fix Delegates authorization bypass in BudgetStatement mutations Three mutations (budgetStatementsBatchAdd, budgetLineItemsBatchAdd, budgetLineItemsBatchUpdate) skipped authorization checks when ownerType was "Delegates", allowing any authenticated user to create/modify Delegates budget data. Now properly enforces the DelegatesAuditor role via Auth.canUpdate for all three mutations. Co-Authored-By: Claude Opus 4.6 --- .../BudgetStatement/schema/budgetStatement.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/modules/BudgetStatement/schema/budgetStatement.ts b/src/modules/BudgetStatement/schema/budgetStatement.ts index 8aa97c3..a2cd1ba 100644 --- a/src/modules/BudgetStatement/schema/budgetStatement.ts +++ b/src/modules/BudgetStatement/schema/budgetStatement.ts @@ -756,7 +756,7 @@ export const resolvers = { input[0].ownerId, ); } - if (parseInt(allowed[0].count) > 0 || input[0].ownerId === null) { + if (parseInt(allowed[0].count) > 0) { if (input.length < 1) { throw new Error("No input data"); } @@ -803,15 +803,17 @@ export const resolvers = { throw new Error("Account disabled. Reach admin for more info."); } const cuIdFromInput = input.pop(); - let allowed = { count: 0 }; - if (cuIdFromInput.ownerType !== "Delegates") { + let allowed: any = { count: 0 }; + if (cuIdFromInput.ownerType === "Delegates") { + [allowed] = await dataSources.db.Auth.canUpdate(userObj.id, "Delegates", null); + } else { [allowed] = await dataSources.db.Auth.canUpdateCoreUnit( userObj.id, cuIdFromInput.ownerType, cuIdFromInput.cuId, ); } - if (allowed.count > 0 || cuIdFromInput.ownerType === "Delegates") { + if (parseInt(allowed.count) > 0) { //Tacking Change let CU; if (cuIdFromInput.ownerType === "Delegates") { @@ -961,15 +963,17 @@ export const resolvers = { throw new Error("Account disabled. Reach admin for more info."); } const cuIdFromInput = input.pop(); - let allowed = { count: 0 }; - if (cuIdFromInput.ownerType !== "Delegates") { + let allowed: any = { count: 0 }; + if (cuIdFromInput.ownerType === "Delegates") { + [allowed] = await dataSources.db.Auth.canUpdate(userObj.id, "Delegates", null); + } else { [allowed] = await dataSources.db.Auth.canUpdateCoreUnit( userObj.id, cuIdFromInput.ownerType, cuIdFromInput.cuId, ); } - if (allowed.count > 0 || cuIdFromInput.ownerType === "Delegates") { + if (parseInt(allowed.count) > 0) { //Tacking Change let CU; if (cuIdFromInput.ownerType === "Delegates") {