diff --git a/.env.production.example b/.env.production.example index b3a5f205..2dbae15a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -11,11 +11,18 @@ LOCAL_DATABASE_URL=postgresql://ndsep_user:CHANGE_ME_STRONG_PASSWORD@postgres:54 NDSEP_PG_URL=postgresql://ndsep_user:CHANGE_ME_STRONG_PASSWORD@postgres:5432/ndsep_db?sslmode=require # ── Redis ──────────────────────────────────────────────────── +# Production requires a private TLS endpoint. Do not use redis:// or disable certificate verification. REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD -REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379 +REDIS_URL=rediss://:CHANGE_ME_REDIS_PASSWORD@redis-tls.internal:6380 +REDIS_TLS=true +REDIS_TLS_CA_PATH=/run/secrets/redis-ca.crt +REDIS_TLS_REJECT_UNAUTHORIZED=true # ── Auth / JWT ─────────────────────────────────────────────── JWT_SECRET=CHANGE_ME_AT_LEAST_32_CHARS_RANDOM_STRING +API_KEY_SALT=CHANGE_ME_AT_LEAST_32_CHARS_RANDOM_STRING +WEBHOOK_SIGNING_SECRET=CHANGE_ME_AT_LEAST_32_CHARS_RANDOM_STRING +WORKER_EVENT_HMAC_SECRET=CHANGE_ME_AT_LEAST_32_CHARS_RANDOM_STRING VITE_APP_ID=your-manus-app-id OAUTH_SERVER_URL=https://api.manus.im VITE_OAUTH_PORTAL_URL=https://manus.im @@ -59,9 +66,9 @@ TEMPORAL_ADDRESS=temporal:7233 TEMPORAL_NAMESPACE=ndsep # ── Keycloak (IAM) ─────────────────────────────────────────── -# Internal issuer URL used by API services; the public hostname below is -# required for browser redirects and must be covered by the Caddy certificate. -KEYCLOAK_URL=http://keycloak:8080 +# Both service and issuer URLs must use TLS in production. The issuer hostname +# must match the Keycloak token `iss` claim and be covered by the Caddy certificate. +KEYCLOAK_URL=https://keycloak.internal:8443 KEYCLOAK_ISSUER_URL=https://sso.ndsep.nitda.gov.ng KEYCLOAK_REALM=ndsep KEYCLOAK_CLIENT_ID=ndsep-platform @@ -72,22 +79,26 @@ KEYCLOAK_ADMIN_USER=CHANGE_ME_NONDEFAULT_ADMIN_USER KEYCLOAK_ADMIN_PASSWORD=CHANGE_ME_STRONG_KEYCLOAK_ADMIN_PASSWORD # ── Permify (Fine-Grained Authorization) ───────────────────── -PERMIFY_URL=http://permify:3476 +PERMIFY_ENABLED=true +PERMIFY_URL=https://permify.internal:3476 +PERMIFY_AUTH_TOKEN=CHANGE_ME_AT_LEAST_32_CHARS_RANDOM_STRING # ── TigerBeetle (Financial Ledger) ─────────────────────────── TIGERBEETLE_ADDRESS=tigerbeetle:3001 TIGERBEETLE_CLUSTER_ID=0 # ── OpenSearch (Full-Text Search) ──────────────────────────── -OPENSEARCH_URL=http://opensearch:9200 -OPENSEARCH_USER=admin -OPENSEARCH_PASS=CHANGE_ME +# Set explicitly to true only after a TLS endpoint and non-placeholder credentials exist. +OPENSEARCH_ENABLED=true +OPENSEARCH_URL=https://opensearch.internal:9200 +OPENSEARCH_USER=CHANGE_ME_NONDEFAULT_OPENSEARCH_USER +OPENSEARCH_PASS=CHANGE_ME_STRONG_OPENSEARCH_PASSWORD # ── Public Edge / APISIX (API Gateway) ─────────────────────── # Caddy is the only service bound to public 80/443. Do not expose APISIX 9080, # 9443, or 9180 on the host. NDSEP_PUBLIC_HOST=ndsep.nitda.gov.ng -APISIX_ADMIN_URL=http://apisix:9180 +APISIX_ADMIN_URL=https://apisix-admin.internal:9180 APISIX_ADMIN_KEY=CHANGE_ME_32_PLUS_CHAR_RANDOM_ADMIN_KEY # ── Dapr (Sidecar Runtime) ─────────────────────────────────── @@ -113,6 +124,12 @@ OPENAPPSEC_OPERATOR_EMAIL=security-operations@ndsep.nitda.gov.ng OPENAPPSEC_AGENT_TOKEN= # ── Lakehouse (Data Lake) ──────────────────────────────────── +# Keep disabled unless the HTTPS catalog and non-default object-storage credentials +# have been provisioned through an approved secret manager. +LAKEHOUSE_ENABLED=false +LAKEHOUSE_CATALOG_URL=https://lakehouse-catalog.internal:8181 +LAKEHOUSE_S3_ACCESS_KEY=CHANGE_ME_AT_LEAST_16_CHARS +LAKEHOUSE_S3_SECRET_KEY=CHANGE_ME_AT_LEAST_16_CHARS ICEBERG_CATALOG_URL=http://iceberg-rest:8181 # ── CORS ────────────────────────────────────────────────────── @@ -120,6 +137,22 @@ CORS_ORIGINS=https://ndsep.nitda.gov.ng,https://app.ndsep.ng # ── Security ───────────────────────────────────────────────── ENABLE_DEMO_LOGIN=false +# Keep disabled unless an approved SMS provider key and accountable recipient are configured. +TERMII_ENABLED=false +TERMII_API_KEY=CHANGE_ME +NDPC_PHONE_NUMBER=+2348012345679 +# Required by production Compose; load only from an approved secret manager. +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/CHANGE_ME +PAGERDUTY_INTEGRATION_KEY=CHANGE_ME +NITDA_COMPLIANCE_EMAIL=security-operations@example.ng +PLATFORM_URL=https://ndsep.example.ng +SMTP_HOST=smtp.example.ng:587 +SMTP_FROM=ndsep-alerts@example.ng +NETBOX_TOKEN=CHANGE_ME_AT_LEAST_32_CHARS_RANDOM_STRING +RANGER_URL=https://ranger.internal:6080 +RANGER_ADMIN_USER=CHANGE_ME_NONDEFAULT_RANGER_USER +RANGER_ADMIN_PASS=CHANGE_ME_STRONG_RANGER_PASSWORD +EVIDENCE_SIGNING_KEY=CHANGE_ME_SECRET_MANAGER_REFERENCE_OR_KEY_MATERIAL # ── Field-Level Encryption (AES-256-GCM) ───────────────────── # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..7937e334 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,43 @@ +# NDSEP security and production-release ownership +# +# The protected production branch must require code-owner review and at least +# two independent approvals. This file routes the security-sensitive paths to +# the repository owner until an authorized administrator replaces or augments +# it with managed security, platform, and compliance teams. +# +# This file does not grant access or replace GitHub branch/environment rules. +# It is intentionally source-controlled so review routing is auditable. + +# Dependency-exception governance and scheduled enforcement +/.github/security/pnpm-audit-exceptions.json @munisp +/PNPM_AUDIT_EXCEPTION_POLICY.md @munisp +/scripts/ci/assert-pnpm-audit.mjs @munisp +/scripts/ci/weekly-pnpm-exception-audit.mjs @munisp +/.github/workflows/security-gate.yml @munisp +/.github/workflows/weekly-exception-governance-audit.yml @munisp + +# Release artifact, container, and production startup controls +/Dockerfile @munisp +/docker-compose.production.yml @munisp +/Makefile @munisp +/scripts/security/ @munisp +/.github/workflows/ci.yml @munisp +/.github/workflows/emergency-candidate-preflight.yml @munisp +/.github/workflows/digest-bound-emergency-release.yml @munisp +/.github/security/break-glass-authorization-policy.json @munisp +/.github/security/break-glass-alert-delivery-policy.json @munisp +/scripts/ci/verify-break-glass-authorization.mjs @munisp +/scripts/ci/generate-break-glass-evidence.mjs @munisp +/scripts/ci/verify-break-glass-evidence.mjs @munisp +/scripts/ci/publish-break-glass-alerts.mjs @munisp +/.env.production.example @munisp + +# Authorization, identity, and policy enforcement +/server/envValidation.ts @munisp +/server/workerEventAuth.ts @munisp +/server/middlewareExtensions.ts @munisp +/server/opensearchConfig.ts @munisp +/server/keycloak.ts @munisp +/server/keycloakAuth.ts @munisp +/infra/opa/ @munisp +/orchestration/keycloak/ @munisp diff --git a/.github/security/break-glass-alert-delivery-policy.json b/.github/security/break-glass-alert-delivery-policy.json new file mode 100644 index 00000000..9727530b --- /dev/null +++ b/.github/security/break-glass-alert-delivery-policy.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "ndsep.break-glass-alert-delivery-policy.v1", + "policyId": "ndsep-production-break-glass-alert-delivery", + "repository": "munisp/ndsep", + "environment": "production-release", + "enabled": false, + "allowedSiemModes": [ + "splunk-hec", + "elastic" + ], + "pagerDutyEndpoint": "https://events.pagerduty.com/v2/enqueue", + "attestationRequired": true, + "externalImmutableRetentionRequired": true, + "noReadinessCredit": true +} diff --git a/.github/security/break-glass-authorization-policy.json b/.github/security/break-glass-authorization-policy.json new file mode 100644 index 00000000..bc8efb2a --- /dev/null +++ b/.github/security/break-glass-authorization-policy.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "ndsep.break-glass-authorization-policy.v1", + "policyId": "ndsep-production-break-glass", + "repository": "munisp/ndsep", + "environment": "production-release", + "enabled": false, + "maximumAuthorizationMinutes": 60, + "requiredIssueLabels": [ + "incident", + "break-glass" + ], + "roles": { + "incident_commander": { + "allowedLogins": [] + }, + "release_authority": { + "allowedLogins": [] + }, + "database_authority": { + "allowedLogins": [] + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aad74059..31cc1676 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,22 +34,47 @@ jobs: --health-retries 5 env: DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_test + WEBHOOK_LEDGER_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_test + PAYMENT_COMMAND_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_test + THEME_PREFERENCES_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_theme_preferences_integration + AUDIT_HASH_CHAIN_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_audit_chain_integration + WEBHOOK_SHADOW_QUEUE_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_webhook_shadow_integration + MULTITENANCY_RLS_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_test JWT_SECRET: test-jwt-secret-for-ci-only FIELD_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef NODE_ENV: test steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: { version: "10.4.1" } + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm exec drizzle-kit push --force - name: Apply database migrations + - run: pnpm exec drizzle-kit migrate + name: Apply root Drizzle migrations - run: pnpm exec tsc --noEmit name: TypeScript check + - name: Create isolated PostgreSQL database for theme-preferences router integration + env: + PGPASSWORD: ndsep_test_pass + run: | + set -euo pipefail + psql --host=localhost --username=ndsep_user --dbname=postgres --set=ON_ERROR_STOP=1 <<'SQL' + DROP DATABASE IF EXISTS ndsep_theme_preferences_integration; + DROP DATABASE IF EXISTS ndsep_audit_chain_integration; + DROP DATABASE IF EXISTS ndsep_webhook_shadow_integration; + CREATE DATABASE ndsep_theme_preferences_integration; + CREATE DATABASE ndsep_audit_chain_integration; + CREATE DATABASE ndsep_webhook_shadow_integration; + SQL + - name: Apply root Drizzle migrations to isolated webhook database + run: | + set -euo pipefail + DATABASE_URL="$WEBHOOK_SHADOW_QUEUE_TEST_DATABASE_URL" pnpm exec drizzle-kit migrate - run: pnpm lint name: Lint the root web and API project - run: pnpm test --reporter=verbose @@ -83,6 +108,7 @@ jobs: --health-retries 5 env: PAYMENT_AUDIT_POSTGRES_URL: postgresql://ndsep_payment_test:ndsep_payment_test_pass@localhost:5432/idlr_payment_test + MOBILE_POSTGRES_TEST_DATABASE_URL: postgresql://ndsep_payment_test:ndsep_payment_test_pass@localhost:5432/idlr_payment_test defaults: run: working-directory: mobile @@ -98,6 +124,10 @@ jobs: cache: pnpm cache-dependency-path: mobile/pnpm-lock.yaml - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm exec drizzle-kit migrate + name: Apply mobile PostgreSQL migrations to disposable CI database + env: + DATABASE_URL: ${{ env.MOBILE_POSTGRES_TEST_DATABASE_URL }} - run: pnpm check name: Mobile TypeScript check - run: pnpm lint @@ -111,12 +141,47 @@ jobs: go-ci: name: Go CI (Build + Vet + Test) runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ndsep_user + POSTGRES_PASSWORD: ndsep_test_pass + POSTGRES_DB: ndsep_test + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + # GitHub Actions' disposable postgres service has no TLS listener; this URL is test-only. + WEBHOOK_QUEUE_TEST_DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@localhost:5432/ndsep_test_webhook_worker_cutover?sslmode=disable steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} cache-dependency-path: workers/go/go.sum + - name: Prepare migration-owned webhook worker integration database + env: + PGPASSWORD: ndsep_test_pass + run: | + set -euo pipefail + pnpm install --frozen-lockfile --ignore-scripts + psql --host=localhost --username=ndsep_user --dbname=postgres --set=ON_ERROR_STOP=1 <<'SQL' + DROP DATABASE IF EXISTS ndsep_test_webhook_worker_cutover; + CREATE DATABASE ndsep_test_webhook_worker_cutover; + SQL + DATABASE_URL="$WEBHOOK_QUEUE_TEST_DATABASE_URL" pnpm exec drizzle-kit migrate - name: Download Go dependencies working-directory: workers/go run: go mod download @@ -172,6 +237,22 @@ jobs: python-ci: name: Python CI (Lint + Test) runs-on: ubuntu-latest + env: + DPCO_ANALYTICS_DATABASE_URL: postgresql://ndsep_ci:ndsep_ci_local_only@127.0.0.1:5432/ndsep_ci?sslmode=disable + services: + postgres: + image: postgres:16.4-alpine + env: + POSTGRES_USER: ndsep_ci + POSTGRES_PASSWORD: ndsep_ci_local_only + POSTGRES_DB: ndsep_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ndsep_ci -d ndsep_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -181,7 +262,7 @@ jobs: - name: Install dependencies run: | pip install --upgrade pip - pip install flake8 pytest pytest-asyncio + pip install flake8 pytest pytest-asyncio fastapi uvicorn pydantic requests "psycopg[binary,pool]" find orchestration services/python -name requirements.txt -exec pip install -r {} \; - name: Lint with flake8 run: flake8 orchestration/ workers/python/ --max-line-length=120 --ignore=E501,W503,W504 @@ -206,6 +287,22 @@ jobs: name: Rust CI (Check + Test) runs-on: ubuntu-latest timeout-minutes: 20 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ndsep_rust_ci + POSTGRES_PASSWORD: ndsep_rust_ci_local_only + POSTGRES_DB: ndsep_rust_ci + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LAKEHOUSE_INGEST_TEST_DATABASE_URL: postgresql://ndsep_rust_ci:ndsep_rust_ci_local_only@127.0.0.1:5432/ndsep_rust_ci + LAKEHOUSE_WRITER_TEST_DATABASE_URL: postgresql://ndsep_rust_ci:ndsep_rust_ci_local_only@127.0.0.1:5432/ndsep_rust_ci steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -307,15 +404,17 @@ jobs: SKIP_DATABASE_MIGRATIONS: "true" steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 - with: { version: "10.4.1" } + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - run: pnpm install --frozen-lockfile - - name: Apply database migrations - run: pnpm exec drizzle-kit push --force + - name: Apply root Drizzle migrations + run: pnpm exec drizzle-kit migrate - run: pnpm exec playwright install --with-deps chromium - name: Start server and verify readiness run: | @@ -339,16 +438,79 @@ jobs: path: test-results/ # ───────────────────────────────────────────────────────────────────────────── - # Docker build + push (on main branch only) + # Schema drift verification — protected, read-only staging comparison + # ───────────────────────────────────────────────────────────────────────────── + schema-drift-verification: + name: Protected Staging Schema Drift Verification + runs-on: ubuntu-latest + # Reuse the intended production release boundary. Authorized administrators + # must configure required reviewers, prevent bypass, and load read-only + # baseline/staging URLs before this gate can execute. + environment: + name: production-release + if: github.ref == 'refs/heads/production' + permissions: + contents: read + env: + SCHEMA_DRIFT_BASELINE_DATABASE_URL: ${{ secrets.SCHEMA_DRIFT_BASELINE_DATABASE_URL }} + SCHEMA_DRIFT_STAGING_DATABASE_URL: ${{ secrets.SCHEMA_DRIFT_STAGING_DATABASE_URL }} + # Optional PEM bundle for a private staging PostgreSQL CA. Verification + # remains mandatory whether this secret is supplied or the runner trust store is used. + SCHEMA_DRIFT_DB_SSL_CA_PEM: ${{ secrets.SCHEMA_DRIFT_DB_SSL_CA_PEM }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - name: Install locked dependency graph + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Compare protected staging schema with clean baseline + run: node scripts/ci/verify-staging-schema-drift.mjs > schema-drift-verification.json + - name: Upload sanitized schema-drift result + if: always() + uses: actions/upload-artifact@v4 + with: + name: schema-drift-verification-${{ github.sha }} + path: schema-drift-verification.json + if-no-files-found: warn + retention-days: 365 + + # ───────────────────────────────────────────────────────────────────────────── + # Docker candidate build + signed digest evidence (protected production push) # ───────────────────────────────────────────────────────────────────────────── docker: name: Docker Build & Push runs-on: ubuntu-latest - needs: [node-ci, go-ci, python-ci, security] + # This environment must be created and protected by an authorized GitHub + # administrator. Its reviewers gate registry publication after all CI passes. + environment: + name: production-release + needs: + [ + node-ci, + go-ci, + go-orchestration-ci, + python-ci, + rust-ci, + security, + integration, + schema-drift-verification, + ] permissions: contents: read packages: write + id-token: write + attestations: write if: github.ref == 'refs/heads/production' + env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + COSIGN_OIDC_ISSUER: https://token.actions.githubusercontent.com + COSIGN_CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/ci.yml@refs/heads/production steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 @@ -357,15 +519,109 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v5 + - name: Build and publish immutable candidate + id: build_image + uses: docker/build-push-action@v5 with: context: . push: true - tags: | - ghcr.io/${{ github.repository }}:latest - ghcr.io/${{ github.repository }}:${{ github.sha }} + tags: ${{ env.IMAGE_NAME }}:candidate-${{ github.sha }} + provenance: mode=max + sbom: true cache-from: type=gha cache-to: type=gha,mode=max build-args: | BUILD_SHA=${{ github.sha }} BUILD_DATE=${{ github.event.head_commit.timestamp }} + - name: Resolve immutable image reference + id: image + shell: bash + run: | + set -euo pipefail + digest='${{ steps.build_image.outputs.digest }}' + [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "ref=${IMAGE_NAME}@${digest}" >> "$GITHUB_OUTPUT" + - name: Direct candidate image scan (HIGH/CRITICAL gate) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ steps.image.outputs.ref }} + format: json + output: release-evidence/trivy-image.json + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: "1" + vuln-type: os,library + - name: Generate candidate CycloneDX SBOM + if: success() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ steps.image.outputs.ref }} + format: cyclonedx + output: release-evidence/image-sbom.cdx.json + vuln-type: os,library + - name: Verify digest-bound scan and SBOM evidence + if: success() + run: | + bash scripts/security/verify-release-image-evidence.sh \ + "$IMAGE_NAME" \ + "${{ steps.image.outputs.digest }}" \ + "${{ github.sha }}" \ + release-evidence/trivy-image.json \ + release-evidence/image-sbom.cdx.json \ + release-evidence/release-evidence.json + - name: Attest build provenance to GHCR + if: success() + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + - name: Install Cosign + if: success() + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + - name: Keylessly sign verified image digest + if: success() + run: cosign sign --yes "${{ steps.image.outputs.ref }}" + - name: Verify keyless signature and GitHub provenance + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cosign verify \ + --certificate-identity "$COSIGN_CERTIFICATE_IDENTITY" \ + --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \ + --output json \ + "${{ steps.image.outputs.ref }}" > release-evidence/cosign-verify.json + gh attestation verify \ + "oci://${{ steps.image.outputs.ref }}" \ + --repo "$GITHUB_REPOSITORY" > release-evidence/github-provenance-verify.txt + - name: Normalize verified candidate evidence for readiness gate + if: success() + env: + EVIDENCE_URI_PREFIX: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/release-evidence-${{ github.sha }} + run: | + set -euo pipefail + node scripts/ci/normalize-release-evidence.mjs \ + --image "${{ steps.image.outputs.ref }}" \ + --source-commit "${{ github.sha }}" \ + --built-at "${{ github.event.head_commit.timestamp }}" \ + --trivy release-evidence/trivy-image.json \ + --trivy-uri "$EVIDENCE_URI_PREFIX/trivy-image.json" \ + --sbom release-evidence/image-sbom.cdx.json \ + --sbom-uri "$EVIDENCE_URI_PREFIX/image-sbom.cdx.json" \ + --provenance release-evidence/github-provenance-verify.txt \ + --provenance-uri "$EVIDENCE_URI_PREFIX/github-provenance-verify.txt" \ + --cosign release-evidence/cosign-verify.json \ + --cosign-uri "$EVIDENCE_URI_PREFIX/cosign-verify.json" \ + --certificate-identity "$COSIGN_CERTIFICATE_IDENTITY" \ + --out-dir release-evidence + - name: Upload release evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-evidence-${{ github.sha }} + path: release-evidence/ + if-no-files-found: warn + retention-days: 365 diff --git a/.github/workflows/digest-bound-emergency-release.yml b/.github/workflows/digest-bound-emergency-release.yml new file mode 100644 index 00000000..d04c7138 --- /dev/null +++ b/.github/workflows/digest-bound-emergency-release.yml @@ -0,0 +1,325 @@ +name: Digest-Bound Emergency Release Authorization + +on: + workflow_dispatch: + inputs: + incident_issue: + description: "Incident issue containing the canonical authorization record and independent approvals" + required: true + type: string + source_commit: + description: "Full SHA of the already reviewed production hotfix commit" + required: true + type: string + candidate_digest: + description: "Existing signed candidate digest, e.g. sha256:<64 lowercase hex>" + required: true + type: string + confirm: + description: "Type AUTHORIZE_DIGEST_BOUND_BREAK_GLASS to create a one-time authorization evidence pack" + required: true + type: string + alert_delivery_confirm: + description: "Optional: type DELIVER_SANITIZED_BREAK_GLASS_EVENTS only after confirming protected SIEM and PagerDuty routes" + required: false + default: "" + type: string + +permissions: {} + +concurrency: + group: digest-bound-break-glass-${{ inputs.source_commit }} + cancel-in-progress: false + +jobs: + authorize: + name: Verify Independent Emergency Authorization and Candidate Evidence + if: >- + github.ref == 'refs/heads/production' && + inputs.confirm == 'AUTHORIZE_DIGEST_BOUND_BREAK_GLASS' + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: production-release + permissions: + contents: read + issues: read + packages: read + id-token: write + attestations: write + env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + COSIGN_OIDC_ISSUER: https://token.actions.githubusercontent.com + COSIGN_CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/emergency-candidate-preflight.yml@refs/heads/production + AUTHORIZATION_POLICY: .github/security/break-glass-authorization-policy.json + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Bind dispatch to the current protected production commit + env: + REQUESTED_SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + [[ "$REQUESTED_SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] + [[ "$REQUESTED_SOURCE_COMMIT" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + + - name: Validate emergency input shape + env: + INCIDENT_ISSUE: ${{ inputs.incident_issue }} + CANDIDATE_DIGEST: ${{ inputs.candidate_digest }} + run: | + set -euo pipefail + [[ "$INCIDENT_ISSUE" =~ ^[1-9][0-9]*$ ]] + [[ "$CANDIDATE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] + + - name: Verify issue-bound independent authorizations + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p break-glass-work + node scripts/ci/verify-break-glass-authorization.mjs \ + --issue "${{ inputs.incident_issue }}" \ + --repository "$GITHUB_REPOSITORY" \ + --candidate-digest "${{ inputs.candidate_digest }}" \ + --source-commit "${{ inputs.source_commit }}" \ + --dispatch-actor "${{ github.actor }}" \ + --policy "$AUTHORIZATION_POLICY" \ + --out-dir break-glass-work + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve pre-existing immutable candidate reference + id: image + env: + CANDIDATE_DIGEST: ${{ inputs.candidate_digest }} + run: | + set -euo pipefail + echo "ref=${IMAGE_NAME}@${CANDIDATE_DIGEST}" >> "$GITHUB_OUTPUT" + + - name: Direct candidate image scan (HIGH/CRITICAL gate) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ steps.image.outputs.ref }} + format: json + output: break-glass-work/trivy-image.json + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: "1" + vuln-type: os,library + + - name: Generate candidate CycloneDX SBOM + if: success() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ steps.image.outputs.ref }} + format: cyclonedx + output: break-glass-work/image-sbom.cdx.json + vuln-type: os,library + + - name: Bind direct-scan and SBOM evidence to the authorized digest + if: success() + run: | + set -euo pipefail + bash scripts/security/verify-release-image-evidence.sh \ + "$IMAGE_NAME" \ + "${{ inputs.candidate_digest }}" \ + "${{ inputs.source_commit }}" \ + break-glass-work/trivy-image.json \ + break-glass-work/image-sbom.cdx.json \ + break-glass-work/release-evidence.json + + - name: Install Cosign + if: success() + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + + - name: Verify signed candidate and GitHub provenance + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cosign verify \ + --certificate-identity "$COSIGN_CERTIFICATE_IDENTITY" \ + --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \ + --output json \ + "${{ steps.image.outputs.ref }}" > break-glass-work/cosign-verify.json + gh attestation verify \ + "oci://${{ steps.image.outputs.ref }}" \ + --repo "$GITHUB_REPOSITORY" > break-glass-work/github-provenance-verify.txt + + - name: Generate tamper-evident break-glass evidence pack + if: success() + run: | + set -euo pipefail + node scripts/ci/generate-break-glass-evidence.mjs \ + --authorization-verification break-glass-work/break-glass-authorization-verification.json \ + --release-evidence break-glass-work/release-evidence.json \ + --cosign-verification break-glass-work/cosign-verify.json \ + --provenance-verification break-glass-work/github-provenance-verify.txt \ + --repository "$GITHUB_REPOSITORY" \ + --source-commit "${{ inputs.source_commit }}" \ + --candidate-digest "${{ inputs.candidate_digest }}" \ + --run-id "$GITHUB_RUN_ID" \ + --actor "${{ github.actor }}" \ + --workflow-ref "https://github.com/${{ github.repository }}/actions/workflows/digest-bound-emergency-release.yml@refs/heads/production" \ + --generated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \ + --out-dir break-glass-evidence + + - name: Attest the break-glass evidence root + id: attest_evidence + if: success() + uses: actions/attest@0fae56888724ae3bba68e14ff82712d5c3d8e1f0 # v2.2.0 + with: + subject-path: break-glass-evidence/break-glass-evidence-root.json + predicate-type: https://ndsep.example.org/attestation/break-glass-evidence/v1 + predicate-path: break-glass-evidence/break-glass-evidence-manifest.json + + - name: Preserve attestation reference + if: success() + env: + ATTESTATION_ID: ${{ steps.attest_evidence.outputs.attestation-id }} + ATTESTATION_URL: ${{ steps.attest_evidence.outputs.attestation-url }} + ATTESTATION_BUNDLE: ${{ steps.attest_evidence.outputs.bundle-path }} + run: | + set -euo pipefail + [[ -n "$ATTESTATION_ID" && -n "$ATTESTATION_URL" && -s "$ATTESTATION_BUNDLE" ]] + cp "$ATTESTATION_BUNDLE" break-glass-evidence/github-attestation-bundle.json + jq -n \ + --arg id "$ATTESTATION_ID" \ + --arg url "$ATTESTATION_URL" \ + '{schemaVersion:"ndsep.break-glass-attestation-reference.v1", attestationId:$id, attestationUrl:$url}' \ + > break-glass-evidence/github-attestation-reference.json + + - name: Verify GitHub attestation for the evidence root + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh attestation verify \ + break-glass-evidence/break-glass-evidence-root.json \ + --repo "$GITHUB_REPOSITORY" \ + --predicate-type https://ndsep.example.org/attestation/break-glass-evidence/v1 \ + > break-glass-evidence/github-attestation-verify.txt + + - name: Verify hash-chained evidence before external disclosure + id: evidence_verify + if: success() + run: | + set -euo pipefail + set +e + node scripts/ci/verify-break-glass-evidence.mjs \ + --evidence-dir break-glass-evidence \ + > break-glass-evidence/ledger-verification.log 2>&1 + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + echo 'status=passed' >> "$GITHUB_OUTPUT" + exit 0 + fi + failure_code='evidence-verification-failed' + if grep -Fq 'Break-glass root manifest hash mismatch' break-glass-evidence/ledger-verification.log; then + failure_code='manifest-root-hash-mismatch' + elif grep -Fq 'Break-glass root candidate binding mismatch' break-glass-evidence/ledger-verification.log; then + failure_code='manifest-candidate-binding-mismatch' + elif grep -Fq 'Audit event' break-glass-evidence/ledger-verification.log && grep -Fq 'hash mismatch' break-glass-evidence/ledger-verification.log; then + failure_code='audit-event-hash-mismatch' + elif grep -Fq 'audit event' break-glass-evidence/ledger-verification.log && grep -Fq 'not valid JSON' break-glass-evidence/ledger-verification.log; then + failure_code='audit-event-invalid-json' + elif grep -Fq 'audit root hash mismatch' break-glass-evidence/ledger-verification.log; then + failure_code='audit-root-hash-mismatch' + elif grep -Fq 'artifact' break-glass-evidence/ledger-verification.log && grep -Fq 'sha256 mismatch' break-glass-evidence/ledger-verification.log; then + failure_code='artifact-sha256-mismatch' + fi + echo 'status=failed' >> "$GITHUB_OUTPUT" + echo "failure_code=$failure_code" >> "$GITHUB_OUTPUT" + + - name: Deliver sanitized tamper-detection alert + if: steps.evidence_verify.outputs.status == 'failed' && inputs.alert_delivery_confirm == 'DELIVER_SANITIZED_BREAK_GLASS_EVENTS' + env: + BREAK_GLASS_DELIVERY_CONFIRMATION: DELIVER_SANITIZED_BREAK_GLASS_EVENTS + BREAK_GLASS_ALERT_DELIVERY_POLICY: .github/security/break-glass-alert-delivery-policy.json + BREAK_GLASS_SIEM_MODE: ${{ vars.BREAK_GLASS_SIEM_MODE }} + BREAK_GLASS_SIEM_ENDPOINT: ${{ vars.BREAK_GLASS_SIEM_ENDPOINT }} + BREAK_GLASS_SPLUNK_INDEX: ${{ vars.BREAK_GLASS_SPLUNK_INDEX }} + BREAK_GLASS_ELASTIC_INDEX: ${{ vars.BREAK_GLASS_ELASTIC_INDEX }} + BREAK_GLASS_SIEM_TOKEN: ${{ secrets.BREAK_GLASS_SIEM_TOKEN }} + PAGERDUTY_ROUTING_KEY: ${{ secrets.PAGERDUTY_ROUTING_KEY }} + run: | + set -euo pipefail + node scripts/ci/publish-break-glass-tamper-alert.mjs \ + --repository "$GITHUB_REPOSITORY" \ + --source-commit "${{ inputs.source_commit }}" \ + --candidate-digest "${{ inputs.candidate_digest }}" \ + --run-id "$GITHUB_RUN_ID" \ + --failure-code "${{ steps.evidence_verify.outputs.failure_code }}" \ + --out-dir break-glass-alert-delivery \ + --deliver + + - name: Fail closed after evidence tamper detection + if: steps.evidence_verify.outputs.status == 'failed' + run: | + set -euo pipefail + echo "Break-glass evidence verification failed: ${{ steps.evidence_verify.outputs.failure_code }}" >&2 + exit 1 + + - name: Render sanitized SIEM and pager delivery preview + if: steps.evidence_verify.outputs.status == 'passed' && inputs.alert_delivery_confirm != 'DELIVER_SANITIZED_BREAK_GLASS_EVENTS' + run: | + set -euo pipefail + node scripts/ci/publish-break-glass-alerts.mjs \ + --evidence-dir break-glass-evidence \ + --out-dir break-glass-alert-delivery + + - name: Deliver sanitized SIEM and PagerDuty alerts + if: steps.evidence_verify.outputs.status == 'passed' && inputs.alert_delivery_confirm == 'DELIVER_SANITIZED_BREAK_GLASS_EVENTS' + env: + BREAK_GLASS_DELIVERY_CONFIRMATION: DELIVER_SANITIZED_BREAK_GLASS_EVENTS + BREAK_GLASS_ALERT_DELIVERY_POLICY: .github/security/break-glass-alert-delivery-policy.json + BREAK_GLASS_SIEM_MODE: ${{ vars.BREAK_GLASS_SIEM_MODE }} + BREAK_GLASS_SIEM_ENDPOINT: ${{ vars.BREAK_GLASS_SIEM_ENDPOINT }} + BREAK_GLASS_SPLUNK_INDEX: ${{ vars.BREAK_GLASS_SPLUNK_INDEX }} + BREAK_GLASS_ELASTIC_INDEX: ${{ vars.BREAK_GLASS_ELASTIC_INDEX }} + BREAK_GLASS_SIEM_TOKEN: ${{ secrets.BREAK_GLASS_SIEM_TOKEN }} + PAGERDUTY_ROUTING_KEY: ${{ secrets.PAGERDUTY_ROUTING_KEY }} + run: | + set -euo pipefail + node scripts/ci/publish-break-glass-alerts.mjs \ + --evidence-dir break-glass-evidence \ + --out-dir break-glass-alert-delivery \ + --deliver + + - name: Upload break-glass evidence and alert delivery receipt + if: always() + uses: actions/upload-artifact@v4 + with: + name: break-glass-evidence-${{ inputs.candidate_digest }} + path: | + break-glass-evidence/ + break-glass-alert-delivery/ + if-no-files-found: warn + retention-days: 365 + + - name: Publish non-sensitive authorization summary + if: always() + run: | + { + echo '## Digest-bound emergency release authorization' + echo + echo "- Incident issue: #${{ inputs.incident_issue }}" + echo "- Source commit: \`${{ inputs.source_commit }}\`" + echo "- Candidate digest: \`${{ inputs.candidate_digest }}\`" + echo '- Deployment performed: **no**; this workflow emits authorization evidence only.' + echo '- Readiness credit: **none**.' + echo "- SIEM/PagerDuty delivery: ${{ inputs.alert_delivery_confirm == 'DELIVER_SANITIZED_BREAK_GLASS_EVENTS' && 'requested (sanitized receipt retained)' || 'dry-run preview only' }}." + echo "- Hash-chain verification: ${{ steps.evidence_verify.outputs.status || 'not reached' }}." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/emergency-candidate-preflight.yml b/.github/workflows/emergency-candidate-preflight.yml new file mode 100644 index 00000000..d56f568b --- /dev/null +++ b/.github/workflows/emergency-candidate-preflight.yml @@ -0,0 +1,184 @@ +name: Emergency Candidate Preflight + +on: + workflow_dispatch: + inputs: + incident_issue: + description: "GitHub incident issue number; records the candidate’s emergency context" + required: true + type: string + source_commit: + description: "Full SHA of the current reviewed production commit to build" + required: true + type: string + confirm: + description: "Type PREPARE_SIGNED_EMERGENCY_CANDIDATE to build a non-deployed candidate" + required: true + type: string + +permissions: {} + +concurrency: + group: emergency-candidate-preflight-${{ inputs.source_commit }} + cancel-in-progress: false + +jobs: + preflight: + name: Build Signed Emergency Candidate Without Deployment + if: >- + github.ref == 'refs/heads/production' && + inputs.confirm == 'PREPARE_SIGNED_EMERGENCY_CANDIDATE' + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: production-release + permissions: + contents: read + packages: write + id-token: write + attestations: write + env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + COSIGN_OIDC_ISSUER: https://token.actions.githubusercontent.com + COSIGN_CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/emergency-candidate-preflight.yml@refs/heads/production + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Bind dispatch to the current protected production commit + env: + REQUESTED_SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + [[ "$REQUESTED_SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] + [[ "$REQUESTED_SOURCE_COMMIT" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + + - name: Validate incident identifier shape + env: + INCIDENT_ISSUE: ${{ inputs.incident_issue }} + run: | + set -euo pipefail + [[ "$INCIDENT_ISSUE" =~ ^[1-9][0-9]*$ ]] + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build an immutable emergency candidate + id: build_image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ env.IMAGE_NAME }}:emergency-candidate-${{ github.sha }} + provenance: mode=max + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_SHA=${{ github.sha }} + BUILD_DATE=${{ github.event.repository.updated_at }} + + - name: Resolve immutable candidate reference + id: image + run: | + set -euo pipefail + digest='${{ steps.build_image.outputs.digest }}' + [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "ref=${IMAGE_NAME}@${digest}" >> "$GITHUB_OUTPUT" + + - name: Direct candidate image scan (HIGH/CRITICAL gate) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ steps.image.outputs.ref }} + format: json + output: emergency-preflight/trivy-image.json + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: "1" + vuln-type: os,library + + - name: Generate candidate CycloneDX SBOM + if: success() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ steps.image.outputs.ref }} + format: cyclonedx + output: emergency-preflight/image-sbom.cdx.json + vuln-type: os,library + + - name: Bind direct-scan and SBOM evidence to the candidate digest + if: success() + run: | + set -euo pipefail + bash scripts/security/verify-release-image-evidence.sh \ + "$IMAGE_NAME" \ + "${{ steps.image.outputs.digest }}" \ + "${{ github.sha }}" \ + emergency-preflight/trivy-image.json \ + emergency-preflight/image-sbom.cdx.json \ + emergency-preflight/release-evidence.json + + - name: Attest candidate build provenance + if: success() + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + + - name: Install Cosign + if: success() + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + + - name: Keylessly sign verified candidate digest + if: success() + run: cosign sign --yes "${{ steps.image.outputs.ref }}" + + - name: Verify preflight candidate signature and provenance + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cosign verify \ + --certificate-identity "$COSIGN_CERTIFICATE_IDENTITY" \ + --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \ + --output json \ + "${{ steps.image.outputs.ref }}" > emergency-preflight/cosign-verify.json + gh attestation verify \ + "oci://${{ steps.image.outputs.ref }}" \ + --repo "$GITHUB_REPOSITORY" > emergency-preflight/github-provenance-verify.txt + + - name: Write sanitized candidate summary + if: always() + env: + CANDIDATE_DIGEST: ${{ steps.image.outputs.digest }} + INCIDENT_ISSUE: ${{ inputs.incident_issue }} + run: | + set -euo pipefail + mkdir -p emergency-preflight + jq -n \ + --arg incident_issue "$INCIDENT_ISSUE" \ + --arg source_commit "$GITHUB_SHA" \ + --arg candidate_digest "$CANDIDATE_DIGEST" \ + --arg workflow_run_id "$GITHUB_RUN_ID" \ + '{schemaVersion:"ndsep.emergency-candidate-preflight.v1", incidentIssue:($incident_issue|tonumber), sourceCommit:$source_commit, candidateDigest:$candidate_digest, workflowRunId:($workflow_run_id|tonumber), deploymentPerformed:false, readinessCredit:false}' \ + > emergency-preflight/candidate-summary.json + + - name: Upload preflight evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: emergency-candidate-preflight-${{ github.sha }} + path: emergency-preflight/ + if-no-files-found: warn + retention-days: 365 diff --git a/.github/workflows/extended-e2e-matrix.yml b/.github/workflows/extended-e2e-matrix.yml new file mode 100644 index 00000000..94de5409 --- /dev/null +++ b/.github/workflows/extended-e2e-matrix.yml @@ -0,0 +1,194 @@ +name: Extended E2E Matrix + +on: + pull_request: + paths: + - "client/**" + - "server/**" + - "shared/**" + - "drizzle/**" + - "e2e/**" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/extended-e2e-matrix.yml" + - "scripts/ci/verify-e2e-matrix-artifacts.mjs" + - "scripts/ci/write-e2e-shard-manifest.mjs" + - "scripts/ci/verify-visual-baseline-review.mjs" + - "e2e/visual-baseline-contract.json" + - "e2e/visual-baseline-review.json" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + e2e-shard: + name: E2E — ${{ matrix.id }} (${{ matrix.project }}) + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - id: auth + spec: e2e/auth.spec.ts + project: chromium + expected_tests: 17 + - id: critical-flows + spec: e2e/critical-flows.spec.ts + project: chromium + expected_tests: 34 + - id: dpco-onboarding + spec: e2e/dpco-onboarding.spec.ts + project: chromium + expected_tests: 20 + - id: enforcement-loop + spec: e2e/enforcement-loop.spec.ts + project: chromium + expected_tests: 24 + - id: penalty-enforcement + spec: e2e/penalty-enforcement.spec.ts + project: chromium + expected_tests: 18 + - id: temporal-kafka + spec: e2e/temporal-kafka.spec.ts + project: chromium + expected_tests: 16 + - id: critical-workflows + spec: e2e/tests/critical-workflows.spec.ts + project: chromium + expected_tests: 15 + - id: visual-regression + spec: e2e/visual-regression.spec.ts + project: chromium + expected_tests: 12 + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ndsep_user + POSTGRES_PASSWORD: ndsep_test_pass + POSTGRES_DB: ndsep_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U ndsep_user -d ndsep_test" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@127.0.0.1:5432/ndsep_test + JWT_SECRET: test-jwt-secret-for-ci-only + FIELD_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef + NODE_ENV: test + PORT: "3000" + BASE_URL: http://127.0.0.1:3000 + E2E_BASE_URL: http://127.0.0.1:3000 + SKIP_DATABASE_MIGRATIONS: "true" + E2E_CANDIDATE_COMMIT: ${{ github.sha }} + E2E_SHARD: ${{ matrix.id }}-${{ matrix.project }} + E2E_PROJECT: ${{ matrix.project }} + E2E_TEST_RUN_ID: ${{ github.run_id }}-${{ matrix.id }}-${{ matrix.project }} + E2E_ARTIFACT_DIRECTORY: test-results/${{ matrix.id }}-${{ matrix.project }} + PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/${{ matrix.id }}-${{ matrix.project }}/test-results.json + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install locked dependency graph + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Apply root Drizzle migrations to this disposable PostgreSQL database + run: pnpm exec drizzle-kit migrate > migration.log + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Build and start isolated application + run: | + set -euo pipefail + pnpm run build > build.log 2>&1 + NODE_ENV=test PORT="$PORT" node dist/index.js > server.log 2>&1 & + echo $! > server.pid + for attempt in {1..30}; do + curl --fail --silent --show-error "$BASE_URL/api/health" > health.json && exit 0 + sleep 2 + done + cat server.log + exit 1 + - name: Require independently reviewed visual baseline + if: matrix.id == 'visual-regression' + env: + VISUAL_BASELINE_CONTRACT_PATH: e2e/visual-baseline-contract.json + VISUAL_BASELINE_REVIEW_PATH: e2e/visual-baseline-review.json + VISUAL_SNAPSHOT_DIRECTORY: e2e/visual-regression.spec.ts-snapshots + VISUAL_REVIEW_HEAD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/ci/verify-visual-baseline-review.mjs + - name: Run release-grade E2E shard without retry masking + run: | + pnpm exec playwright test "${{ matrix.spec }}" \ + --project="${{ matrix.project }}" \ + --workers=1 \ + --retries=0 \ + --timeout=60000 \ + --reporter=line,json \ + --output="$E2E_ARTIFACT_DIRECTORY" + - name: Write candidate-bound shard manifest after Playwright output initialization + if: always() + run: node scripts/ci/write-e2e-shard-manifest.mjs + - name: Collect per-shard diagnostics in its evidence directory + if: always() + shell: bash + run: | + set -u + mkdir -p "$E2E_ARTIFACT_DIRECTORY" + for file in server.log build.log migration.log health.json; do + if [[ -f "$file" ]]; then + cp "$file" "$E2E_ARTIFACT_DIRECTORY/$file" + fi + done + - name: Upload per-shard evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-${{ github.sha }}-${{ matrix.id }}-${{ matrix.project }} + path: test-results/${{ matrix.id }}-${{ matrix.project }}/ + if-no-files-found: warn + retention-days: 30 + + verify-e2e-matrix: + name: Verify Extended E2E Matrix Evidence + runs-on: ubuntu-latest + needs: [e2e-shard] + if: always() + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + - name: Download each expected shard artifact + uses: actions/download-artifact@v4 + with: + pattern: e2e-${{ github.sha }}-* + path: e2e-evidence + - name: Require complete, candidate-bound, first-attempt results + env: + E2E_ARTIFACT_ROOT: e2e-evidence + E2E_CANDIDATE_COMMIT: ${{ github.sha }} + E2E_MATRIX_SUMMARY_PATH: e2e-evidence/e2e-matrix-evidence.json + run: node scripts/ci/verify-e2e-matrix-artifacts.mjs + - name: Upload verified E2E matrix evidence index + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-matrix-evidence-${{ github.sha }} + path: e2e-evidence/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/production-go-evidence-verification.yml b/.github/workflows/production-go-evidence-verification.yml new file mode 100644 index 00000000..9681b994 --- /dev/null +++ b/.github/workflows/production-go-evidence-verification.yml @@ -0,0 +1,124 @@ +name: Production GO Evidence Verification + +on: + workflow_dispatch: + inputs: + source_commit: + description: Full SHA of the currently protected production commit + required: true + type: string + staging_run_id: + description: Successful Staging Production GO Acceptance workflow run ID + required: true + type: string + confirm: + description: Type VERIFY_PRODUCTION_GO_EVIDENCE to verify an existing evidence artifact + required: true + type: string + +permissions: {} + +concurrency: + group: production-go-evidence-${{ inputs.source_commit }} + cancel-in-progress: false + +jobs: + verify: + name: Verify Digest-Bound Production GO Evidence + if: >- + github.ref == 'refs/heads/production' && + inputs.confirm == 'VERIFY_PRODUCTION_GO_EVIDENCE' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: production-release + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Bind request to the current protected production commit + env: + REQUESTED_SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + [[ "$REQUESTED_SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] + [[ "$REQUESTED_SOURCE_COMMIT" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + - name: Install locked verifier dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Verify staging workflow provenance and commit binding + env: + GH_TOKEN: ${{ github.token }} + STAGING_RUN_ID: ${{ inputs.staging_run_id }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$STAGING_RUN_ID" =~ ^[1-9][0-9]*$ ]] + gh run view "$STAGING_RUN_ID" --repo "$REPOSITORY" \ + --json headSha,headBranch,status,conclusion,workflowName \ + > staging-workflow.json + jq -e \ + --arg sha "$SOURCE_COMMIT" \ + '(.headSha == $sha) and + (.headBranch == "production") and + (.status == "completed") and + (.conclusion == "success") and + (.workflowName == "Staging Production GO Acceptance")' \ + staging-workflow.json > /dev/null + + - name: Download the named protected staging evidence artifact + env: + GH_TOKEN: ${{ github.token }} + STAGING_RUN_ID: ${{ inputs.staging_run_id }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p upstream-evidence + gh run download "$STAGING_RUN_ID" --repo "$REPOSITORY" \ + --name "staging-go-evidence-$SOURCE_COMMIT" \ + --dir upstream-evidence + test -f upstream-evidence/candidate.json + + - name: Collect validated staging evidence without mutation + run: | + set -euo pipefail + node scripts/ci/collect-staging-release-evidence.mjs \ + --source-dir upstream-evidence \ + --out-dir verified-go-evidence \ + --write \ + --confirm COLLECT_REAL_EVIDENCE + + - name: Enforce the 95-point production GO threshold + run: | + set -euo pipefail + node scripts/ci/verify-production-go-evidence.mjs \ + --evidence-dir verified-go-evidence \ + > production-go-verification.json + + - name: Upload sanitized production GO verification result + if: always() + uses: actions/upload-artifact@v4 + with: + name: production-go-verification-${{ github.sha }} + path: | + staging-workflow.json + production-go-verification.json + if-no-files-found: warn + retention-days: 365 diff --git a/.github/workflows/security-gate.yml b/.github/workflows/security-gate.yml index b2da01bb..c018b37f 100644 --- a/.github/workflows/security-gate.yml +++ b/.github/workflows/security-gate.yml @@ -171,6 +171,146 @@ jobs: if-no-files-found: warn retention-days: 90 + evidence-contract-verification: + name: evidence-contract-verification + # Source-level verification only: this job has no deployment environment, + # no write token, no database target, and no access to production secrets. + # It verifies the code that will later reject malformed governance and + # release-evidence records; it cannot manufacture such evidence. + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + env: + NODE_VERSION: "22" + PNPM_VERSION: "10.34.4" + NODE_ENV: test + steps: + - name: Check out candidate + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set up patched pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - name: Install locked dependency graph without lifecycle scripts + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Type-check verifier and seed-control boundaries + run: pnpm exec tsc --noEmit + - name: Verify fixture, governance, and evidence anti-tampering controls + run: | + pnpm exec vitest run \ + server/security/syntheticSeedSafety.test.ts \ + server/security/syntheticFixturePlan.test.ts \ + server/security/stagingReleaseEvidenceCollector.test.ts \ + server/security/releaseEvidenceNormalizer.test.ts \ + server/security/postMergeGovernanceVerifier.test.ts \ + server/security/releaseArtifactHardening.config.test.ts \ + server/security/breakGlassEmergencyRelease.test.ts \ + server/security/breakGlassSiemPagerIntegration.test.ts \ + server/security/durableAnalyticsPersistence.config.test.ts \ + server/security/lakehouseDurablePersistence.config.test.ts \ + server/security/paymentCommandLifecycle.config.test.ts \ + server/security/apisixDurableRouteRegistry.config.test.ts \ + server/security/permifyPostgresFailClosed.config.test.ts \ + server/security/keycloakPostgresFailClosed.config.test.ts \ + server/security/eventDeliveryDurability.config.test.ts \ + server/security/mojaloopReceiptIntegrity.config.test.ts \ + server/security/mobilePostgresPersistence.config.test.ts \ + server/security/productionGoEvidenceVerifier.test.ts \ + server/security/productionGoEvidenceWorkflow.config.test.ts \ + server/security/stagingProductionGoAcceptanceWorkflow.config.test.ts \ + server/security/stagingRollbackVerifier.config.test.ts \ + server/security/prReadinessCollector.config.test.ts \ + server/security/visualBaselineReviewVerifier.config.test.ts \ + server/security/visualBaselineReminder.config.test.ts \ + server/security/isolatedVisualRegressionRunner.config.test.ts \ + server/security/runtimeSchemaOwnership.config.test.ts \ + server/security/e2eMatrixArtifactsVerifier.test.ts \ + server/security/extendedE2eMatrixWorkflow.config.test.ts \ + server/security/themePreferencesDurableStorage.config.test.ts \ + server/security/threatProtection.test.ts \ + server/security/webhookShadowMonitoring.config.test.ts \ + server/security/webhookWorkerCutover.config.test.ts \ + server/redisRateLimitStore.test.ts \ + --maxWorkers=1 + - name: Verify schema-only reconciliation migration is DDL-only + shell: bash + run: | + set -euo pipefail + test -f drizzle/0035_digital_twin_noc_runtime_schema.sql + grep -Fq '"tag": "0035_digital_twin_noc_runtime_schema"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\\b' drizzle/0035_digital_twin_noc_runtime_schema.sql + test "$(grep -Ec '^CREATE TABLE' drizzle/0035_digital_twin_noc_runtime_schema.sql)" -ge 17 + test -f drizzle/0039_lakehouse_durable_postgres_storage.sql + grep -Fq '"tag": "0039_lakehouse_durable_postgres_storage"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\\b' drizzle/0039_lakehouse_durable_postgres_storage.sql + test "$(grep -Ec '^CREATE TABLE' drizzle/0039_lakehouse_durable_postgres_storage.sql)" -eq 4 + test -f drizzle/0040_payment_command_durable_lifecycle.sql + grep -Fq '"tag": "0040_payment_command_durable_lifecycle"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\\b' drizzle/0040_payment_command_durable_lifecycle.sql + grep -Fq 'CREATE TABLE payment_commands' drizzle/0040_payment_command_durable_lifecycle.sql + test -f drizzle/0041_apisix_durable_route_registry.sql + grep -Fq '"tag": "0041_apisix_durable_route_registry"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\\b' drizzle/0041_apisix_durable_route_registry.sql + grep -Fq 'CREATE TABLE gateway_routes' drizzle/0041_apisix_durable_route_registry.sql + grep -Fq 'CREATE TABLE gateway_route_sync_attempts' drizzle/0041_apisix_durable_route_registry.sql + test -f drizzle/0042_theme_preferences_durable_storage.sql + grep -Fq '"tag": "0042_theme_preferences_durable_storage"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\\b' drizzle/0042_theme_preferences_durable_storage.sql + grep -Fq 'CREATE TABLE "theme_preferences"' drizzle/0042_theme_preferences_durable_storage.sql + test -f drizzle/0043_audit_log_hash_chain_durable.sql + grep -Fq '"tag": "0043_audit_log_hash_chain_durable"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\b' drizzle/0043_audit_log_hash_chain_durable.sql + grep -Fq 'CREATE TRIGGER audit_logs_assign_hash_chain' drizzle/0043_audit_log_hash_chain_durable.sql + grep -Fq 'pg_advisory_xact_lock' drizzle/0043_audit_log_hash_chain_durable.sql + test -f drizzle/0045_webhook_delivery_attempts_shadow_queue.sql + grep -Fq '"tag": "0045_webhook_delivery_attempts_shadow_queue"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\b' drizzle/0045_webhook_delivery_attempts_shadow_queue.sql + grep -Fq 'CREATE TABLE IF NOT EXISTS webhook_delivery_attempts' drizzle/0045_webhook_delivery_attempts_shadow_queue.sql + test -f drizzle/0046_webhook_delivery_attempt_claim_leases.sql + grep -Fq '"tag": "0046_webhook_delivery_attempt_claim_leases"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\b' drizzle/0046_webhook_delivery_attempt_claim_leases.sql + grep -Fq 'webhook_delivery_attempt_processing_claim_check' drizzle/0046_webhook_delivery_attempt_claim_leases.sql + grep -Fq 'webhook_deliveries_queue_attempt_id_fk' drizzle/0046_webhook_delivery_attempt_claim_leases.sql + grep -Fq 'uq_webhook_deliveries_queue_attempt' drizzle/0046_webhook_delivery_attempt_claim_leases.sql + test -f drizzle/0047_webhook_delivery_attempt_lease_window.sql + grep -Fq '"tag": "0047_webhook_delivery_attempt_lease_window"' drizzle/meta/_journal.json + ! grep -Eni '^(INSERT|UPDATE|DELETE|COPY)\b' drizzle/0047_webhook_delivery_attempt_lease_window.sql + grep -Fq 'webhook_delivery_attempt_processing_lease_window_check' drizzle/0047_webhook_delivery_attempt_lease_window.sql + grep -Fq 'claim_expires_at > claimed_at' drizzle/0047_webhook_delivery_attempt_lease_window.sql + - name: Validate fixture-script syntax and explicit refusal paths + shell: bash + run: | + set -euo pipefail + node --check scripts/seed-remaining-synthetic-fixtures.mjs + node --check scripts/verify-synthetic-fixture-scenario.mjs + node --check scripts/ci/verify-break-glass-authorization.mjs + node --check scripts/ci/generate-break-glass-evidence.mjs + node --check scripts/ci/verify-break-glass-evidence.mjs + node --check scripts/ci/publish-break-glass-alerts.mjs + set +e + env -i PATH="$PATH" HOME="$HOME" NODE_ENV=test node scripts/seed-remaining-synthetic-fixtures.mjs > refusal.log 2>&1 + status=$? + set -e + test "$status" -ne 0 + grep -Fq 'SYNTHETIC_SEED_CONFIRMATION' refusal.log + - name: Upload source-control verification log + if: always() + uses: actions/upload-artifact@v4 + with: + name: evidence-contract-verification-${{ github.sha }} + path: refusal.log + if-no-files-found: warn + retention-days: 90 + security-gate: name: security-gate runs-on: ubuntu-24.04 @@ -178,6 +318,7 @@ jobs: - dependency-review - pnpm-audit-baseline - filesystem-vulnerability-scan + - evidence-contract-verification # Ensure the check is unambiguously failed if any required scanner failed. if: always() steps: @@ -188,6 +329,7 @@ jobs: echo "dependency-review=${{ needs.dependency-review.result }}" echo "pnpm-audit-baseline=${{ needs.pnpm-audit-baseline.result }}" echo "filesystem-vulnerability-scan=${{ needs.filesystem-vulnerability-scan.result }}" + echo "evidence-contract-verification=${{ needs.evidence-contract-verification.result }}" # dependency-review is skipped only on non-PR events. if [[ "${{ github.event_name }}" == "pull_request" && "${{ needs.dependency-review.result }}" != "success" ]]; then @@ -195,6 +337,7 @@ jobs: fi [[ "${{ needs.pnpm-audit-baseline.result }}" == "success" ]] [[ "${{ needs.filesystem-vulnerability-scan.result }}" == "success" ]] + [[ "${{ needs.evidence-contract-verification.result }}" == "success" ]] { echo "# Security Gate" diff --git a/.github/workflows/staging-production-go-acceptance.yml b/.github/workflows/staging-production-go-acceptance.yml new file mode 100644 index 00000000..504bcf04 --- /dev/null +++ b/.github/workflows/staging-production-go-acceptance.yml @@ -0,0 +1,117 @@ +name: Staging Production GO Acceptance + +on: + workflow_dispatch: + inputs: + source_commit: + description: Full SHA of the reviewed production candidate + required: true + type: string + upstream_run_id: + description: Successful Staging Integration Acceptance run ID that produced real upstream evidence + required: true + type: string + confirm: + description: Type COLLECT_PROTECTED_STAGING_GO_EVIDENCE to collect existing evidence only + required: true + type: string + +permissions: {} + +concurrency: + group: staging-production-go-acceptance-${{ inputs.source_commit }} + cancel-in-progress: false + +jobs: + collect: + name: Collect Protected Staging GO Evidence + if: >- + github.ref == 'refs/heads/production' && + inputs.confirm == 'COLLECT_PROTECTED_STAGING_GO_EVIDENCE' + runs-on: [self-hosted, linux, ndsep-staging-internal] + timeout-minutes: 20 + environment: + name: staging + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Bind collection request to the current protected production commit + env: + REQUESTED_SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + [[ "$REQUESTED_SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] + [[ "$REQUESTED_SOURCE_COMMIT" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + - name: Install locked evidence collector dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Verify upstream staging acceptance provenance + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM_RUN_ID: ${{ inputs.upstream_run_id }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$UPSTREAM_RUN_ID" =~ ^[1-9][0-9]*$ ]] + gh run view "$UPSTREAM_RUN_ID" --repo "$REPOSITORY" \ + --json headSha,headBranch,status,conclusion,workflowName \ + > upstream-staging-workflow.json + jq -e \ + --arg sha "$SOURCE_COMMIT" \ + '(.headSha == $sha) and + (.headBranch == "production") and + (.status == "completed") and + (.conclusion == "success") and + (.workflowName == "Staging Integration Acceptance")' \ + upstream-staging-workflow.json > /dev/null + + - name: Download immutable upstream evidence without execution + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM_RUN_ID: ${{ inputs.upstream_run_id }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p upstream-evidence + gh run download "$UPSTREAM_RUN_ID" --repo "$REPOSITORY" \ + --name "staging-upstream-evidence-$SOURCE_COMMIT" \ + --dir upstream-evidence + test -f upstream-evidence/candidate.json + + - name: Validate and collect immutable staging evidence + run: | + set -euo pipefail + node scripts/ci/collect-staging-release-evidence.mjs \ + --source-dir upstream-evidence \ + --out-dir staging-go-evidence \ + --write \ + --confirm COLLECT_REAL_EVIDENCE + + - name: Upload sanitized staging GO evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: staging-go-evidence-${{ github.sha }} + path: | + staging-go-evidence/ + upstream-staging-workflow.json + if-no-files-found: warn + retention-days: 365 diff --git a/.github/workflows/staging-rollback-verification.yml b/.github/workflows/staging-rollback-verification.yml new file mode 100644 index 00000000..37dcc368 --- /dev/null +++ b/.github/workflows/staging-rollback-verification.yml @@ -0,0 +1,127 @@ +name: Staging Rollback Verification + +on: + workflow_dispatch: + inputs: + source_commit: + description: Full SHA of the deployed production candidate being verified + required: true + type: string + drill_id: + description: Approved staging-only rollback drill identifier + required: true + type: string + candidate_digest: + description: Immutable candidate image digest that was rolled back + required: true + type: string + rollback_digest: + description: Prior approved immutable image digest now serving traffic + required: true + type: string + confirm: + description: Type EXECUTE_STAGING_ROLLBACK_VERIFICATION to collect real staging evidence + required: true + type: string + +permissions: + contents: read + +concurrency: + group: staging-rollback-verification-${{ inputs.drill_id }} + cancel-in-progress: false + +jobs: + verify: + name: Verify Post-Rollback Traffic and Data Integrity + if: >- + github.ref == 'refs/heads/production' && + inputs.confirm == 'EXECUTE_STAGING_ROLLBACK_VERIFICATION' + runs-on: [self-hosted, linux, ndsep-staging-internal] + environment: + name: staging + timeout-minutes: 30 + permissions: + contents: read + env: + STAGING_SOURCE_COMMIT: ${{ inputs.source_commit }} + STAGING_ROLLBACK_DRILL_ID: ${{ inputs.drill_id }} + STAGING_CANDIDATE_IMAGE_DIGEST: ${{ inputs.candidate_digest }} + STAGING_ROLLBACK_IMAGE_DIGEST: ${{ inputs.rollback_digest }} + STAGING_TEST_BASE_URL: ${{ secrets.STAGING_TEST_BASE_URL }} + STAGING_READONLY_DATABASE_URL: ${{ secrets.STAGING_READONLY_DATABASE_URL }} + STAGING_READONLY_DATABASE_USER: ${{ vars.STAGING_READONLY_DATABASE_USER }} + STAGING_READONLY_DB_HOSTS: ${{ vars.STAGING_READONLY_DB_HOSTS }} + STAGING_AUDIT_CHAIN_START_ID: ${{ vars.STAGING_AUDIT_CHAIN_START_ID }} + STAGING_AUTHORIZATION_PROBE_PATH: ${{ vars.STAGING_AUTHORIZATION_PROBE_PATH }} + STAGING_AUTHORIZATION_PROBE_EXPECTED_STATUS: ${{ vars.STAGING_AUTHORIZATION_PROBE_EXPECTED_STATUS }} + STAGING_MFA_TOKEN: ${{ secrets.STAGING_MFA_TOKEN }} + STAGING_ROLLBACK_EVIDENCE_DIR: staging-rollback-evidence + STAGING_ROLLBACK_STATE_FILE: /var/lib/ndsep-staging-rollback/${{ inputs.drill_id }}/rollback-state.json + STAGING_ASYNC_RECONCILIATION_FILE: /var/lib/ndsep-staging-rollback/${{ inputs.drill_id }}/async-reconciliation.json + STAGING_OBSERVABILITY_EVIDENCE_FILE: /var/lib/ndsep-staging-rollback/${{ inputs.drill_id }}/observability.json + STAGING_RELEASE_SMOKE_RESULT_FILE: staging-rollback-evidence/release-smoke.json + steps: + - name: Check out current protected production commit + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Bind verification to current protected production candidate + shell: bash + run: | + set -euo pipefail + [[ "$STAGING_SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] + [[ "$STAGING_SOURCE_COMMIT" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + [[ "$STAGING_ROLLBACK_DRILL_ID" =~ ^staging-rollback-[a-z0-9-]{8,80}$ ]] + [[ "$STAGING_CANDIDATE_IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] + [[ "$STAGING_ROLLBACK_IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] + [[ "$STAGING_CANDIDATE_IMAGE_DIGEST" != "$STAGING_ROLLBACK_IMAGE_DIGEST" ]] + [[ -f "$STAGING_ROLLBACK_STATE_FILE" ]] + [[ -f "$STAGING_ASYNC_RECONCILIATION_FILE" ]] + [[ -f "$STAGING_OBSERVABILITY_EVIDENCE_FILE" ]] + + - uses: pnpm/action-setup@v4 + with: + version: "10.34.4" + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install locked verifier and browser-test dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Install locked Chromium browser runtime + run: pnpm exec playwright install --with-deps chromium + + - name: Run browser/API release smoke with zero retries + shell: bash + run: | + set -euo pipefail + mkdir -p "$STAGING_ROLLBACK_EVIDENCE_DIR" + set +e + BASE_URL="$STAGING_TEST_BASE_URL" \ + pnpm exec playwright test e2e/release-smoke.spec.ts \ + --project=chromium --workers=1 --retries=0 --reporter=json \ + > "$STAGING_RELEASE_SMOKE_RESULT_FILE" + smoke_status=$? + set -e + echo "$smoke_status" > "$STAGING_ROLLBACK_EVIDENCE_DIR/release-smoke.exit-code" + + - name: Verify traffic, durable state, and reconciliation evidence + run: pnpm exec tsx scripts/security/verify-staging-rollback.ts + + - name: Upload sanitized rollback verification evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: staging-rollback-verification-${{ inputs.drill_id }}-${{ github.sha }} + path: staging-rollback-evidence/ + if-no-files-found: error + retention-days: 365 diff --git a/.gitignore b/.gitignore index c662f320..f22728c2 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,8 @@ client/dev-dist/ .manus/db/ demo-screenshots/ test-results/ +# Generated from an approved production environment file by make docker-prod-up. +.ndsep-runtime/ __pycache__/ workers/python/vendor/ workers/python/models/ diff --git a/Dockerfile b/Dockerfile index dd653134..344ea92d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,57 +1,62 @@ # ============================================================ -# NDSEP API — Multi-stage Production Dockerfile +# NDSEP API — multi-stage, digest-pinned production image +# The final release gate must scan the published OCI digest; this +# Dockerfile alone is not evidence that a candidate is vulnerability-free. # ============================================================ -# ── Stage 1: Dependencies ──────────────────────────────────── -FROM node:22-alpine AS deps +ARG NODE_ALPINE_IMAGE=node:22-alpine3.23@sha256:46825fbbd4e996a78b7a2cdc08d75e38a5a505bdab95dcda55605359bf124bc6 +ARG PNPM_VERSION=10.34.4 + +# ── Stage 1: build dependencies ────────────────────────────── +FROM ${NODE_ALPINE_IMAGE} AS deps +ARG PNPM_VERSION WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN apk upgrade --no-cache \ + && corepack enable \ + && corepack prepare pnpm@${PNPM_VERSION} --activate COPY package.json pnpm-lock.yaml ./ COPY patches ./patches +# Build tooling remains in this stage only. RUN pnpm install --frozen-lockfile --prod=false -# ── Stage 2: Build ─────────────────────────────────────────── -FROM node:22-alpine AS builder +# ── Stage 2: compile application ───────────────────────────── +FROM ${NODE_ALPINE_IMAGE} AS builder +ARG PNPM_VERSION WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN apk upgrade --no-cache \ + && corepack enable \ + && corepack prepare pnpm@${PNPM_VERSION} --activate COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm run build -# ── Stage 3: Production ────────────────────────────────────── -FROM node:22-alpine AS production +# ── Stage 3: production runtime ────────────────────────────── +FROM ${NODE_ALPINE_IMAGE} AS production +ARG PNPM_VERSION WORKDIR /app +# Keep the OS package fix in the final layer too. The release gate confirms +# actual libcrypto3/libssl3 versions and rejects unsupported OS packages. +RUN apk upgrade --no-cache \ + && addgroup -g 1001 -S ndsep \ + && adduser -S ndsep -u 1001 -G ndsep \ + && corepack enable \ + && corepack prepare pnpm@${PNPM_VERSION} --activate -# Security: run as non-root user -RUN addgroup -g 1001 -S ndsep && adduser -S ndsep -u 1001 -G ndsep - -# Install production-only dependencies -RUN corepack enable && corepack prepare pnpm@latest --activate COPY package.json pnpm-lock.yaml ./ COPY patches ./patches -RUN pnpm install --frozen-lockfile --prod --ignore-scripts && pnpm store prune +# Runtime tree only: lifecycle scripts are not executed and no build stage +# node_modules, esbuild binary or source worker directories are copied here. +RUN pnpm install --frozen-lockfile --prod --ignore-scripts \ + && pnpm store prune \ + && test ! -e node_modules/.pnpm/@esbuild+linux-x64@*/node_modules/@esbuild/linux-x64/bin/esbuild -# Copy the actual Vite/esbuild output. Vite writes browser assets beneath -# dist/public and esbuild writes the Node entry point to dist/index.js. COPY --from=builder /app/dist ./dist - -# Retain schema files for explicit migration tooling. Go and Python workers are -# independently built and deployed by docker-compose.production.yml; they do -# not belong in the Node.js API image. COPY --from=builder /app/drizzle ./drizzle - -# Security: set ownership RUN chown -R ndsep:ndsep /app USER ndsep - -# Health check +ENV NODE_ENV=production PORT=3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget -qO- http://localhost:3000/api/health || exit 1 - + CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 EXPOSE 3000 - -ENV NODE_ENV=production -ENV PORT=3000 - CMD ["node", "dist/index.js"] diff --git a/Makefile b/Makefile index d267b354..2264e2bc 100644 --- a/Makefile +++ b/Makefile @@ -135,19 +135,17 @@ workers-clean: ## Remove compiled worker binaries rm -rf $(RUST_DIR)/target/release/ # ─── Docker ─────────────────────────────────────────────────────────────────── -docker-build: ## Build Docker image - @echo "$(GREEN)Building Docker image $(DOCKER_REPO):$(DOCKER_TAG)...$(RESET)" +docker-build: ## Build a local development image only; production publishing is protected CI only + @echo "$(GREEN)Building local development image $(DOCKER_REPO):dev-$(DOCKER_TAG)...$(RESET)" docker build \ --build-arg APP_VERSION=$(APP_VERSION) \ --build-arg BUILD_DATE=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) \ - -t $(DOCKER_REPO):$(DOCKER_TAG) \ - -t $(DOCKER_REPO):latest \ + -t $(DOCKER_REPO):dev-$(DOCKER_TAG) \ . -docker-push: ## Push Docker image to registry - @echo "$(GREEN)Pushing $(DOCKER_REPO):$(DOCKER_TAG)...$(RESET)" - docker push $(DOCKER_REPO):$(DOCKER_TAG) - docker push $(DOCKER_REPO):latest +docker-push: ## Refuse direct image publication; use the protected production workflow + @echo "$(RED)Direct image publishing is disabled. Merge an approved candidate through the protected production CI workflow.$(RESET)" + @false docker-up: ## Start all infrastructure services (dev) @echo "$(GREEN)Starting infrastructure services...$(RESET)" @@ -160,8 +158,17 @@ docker-down: ## Stop all infrastructure services docker-logs: ## Follow logs for all services docker compose logs -f -docker-prod-up: ## Start production stack - docker compose -f docker-compose.production.yml up -d --wait +docker-prod-up: ## Start production stack only after rendered configuration has immutable image digests + @test -n "$(PRODUCTION_ENV_FILE)" || (echo "$(RED)Set PRODUCTION_ENV_FILE to an approved production environment file$(RESET)" && exit 1) + @tmp_config=$$(mktemp); \ + trap 'rm -f "$$tmp_config"' EXIT; \ + node scripts/security/render-alertmanager-config.mjs \ + infra/prometheus/alertmanager.yml \ + .ndsep-runtime/alertmanager.yml \ + "$(PRODUCTION_ENV_FILE)"; \ + docker compose --env-file "$(PRODUCTION_ENV_FILE)" -f docker-compose.production.yml config > "$$tmp_config"; \ + bash scripts/security/verify-production-image-lock.sh "$$tmp_config"; \ + docker compose --env-file "$(PRODUCTION_ENV_FILE)" -f docker-compose.production.yml up -d --wait docker-prod-down: ## Stop production stack docker compose -f docker-compose.production.yml down diff --git a/client/src/App.tsx b/client/src/App.tsx index 7b1d94ef..a3e92541 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -257,6 +257,7 @@ function Router() { + {() => ( @@ -510,6 +511,7 @@ function Router() { {/* Catch-all — must be last */} + )} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index cf1a61a7..68a2359f 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -560,7 +560,7 @@ function NotificationsHeader({ isMobile, activeMenuLabel }: { isMobile: boolean; export default function DashboardLayout({ children, }: { - children: React.ReactNode; + children: React.ReactNode | (() => React.ReactNode); }) { const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY); @@ -653,7 +653,7 @@ export default function DashboardLayout({ } > - {children} + {typeof children === "function" ? children() : children} ); @@ -999,8 +999,8 @@ function DashboardLayoutContent({ -
-
+
+
{children}
diff --git a/client/src/contexts/ThemeContext.tsx b/client/src/contexts/ThemeContext.tsx index 1b79bcbc..bdf171fc 100644 --- a/client/src/contexts/ThemeContext.tsx +++ b/client/src/contexts/ThemeContext.tsx @@ -70,10 +70,19 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { return () => clearTimeout(timer); }, [isDark]); - // Backend sync + // Backend sync is intentionally gated by the real session query. The theme + // preference endpoints are user-scoped protected procedures; querying them + // for an anonymous visitor contaminates the auth.me batch with an expected + // UNAUTHORIZED error and can prevent the sign-in boundary from rendering. + const authQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + const isAuthenticated = Boolean(authQuery.data); const setThemeMutation = trpc.themePrefs.set.useMutation(); const { data: backendPrefs } = trpc.themePrefs.get.useQuery(undefined, { + enabled: isAuthenticated, staleTime: Infinity, retry: false, }); @@ -94,10 +103,11 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { // Backend only accepts "light" | "dark" — resolve "auto" before syncing const backendTheme: "light" | "dark" = newMode === "auto" ? resolve(newMode) : newMode; if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + if (!isAuthenticated) return; syncTimerRef.current = setTimeout(() => { setThemeMutation.mutate({ theme: backendTheme }); }, 500); - }, [setThemeMutation]); + }, [isAuthenticated, setThemeMutation]); const cycleTheme = useCallback(() => { setMode(CYCLE_ORDER[(CYCLE_ORDER.indexOf(mode) + 1) % CYCLE_ORDER.length]); diff --git a/docker-compose-workers-addition.yml b/docker-compose-workers-addition.yml index ea595eb0..9b118636 100644 --- a/docker-compose-workers-addition.yml +++ b/docker-compose-workers-addition.yml @@ -60,9 +60,10 @@ services: container_name: ndsep-apisix-manager environment: WORKER_DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - APISIX_ADMIN_URL: ${APISIX_ADMIN_URL:-http://apisix:9180} - APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:-edd1c9f034335f136f87ad84b625c8f1} - PORT: "8141" + APISIX_ADMIN_URL: ${APISIX_ADMIN_URL:?APISIX_ADMIN_URL is required} + APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:?APISIX_ADMIN_KEY is required} + APISIX_MANAGER_INTERNAL_AUTH_TOKEN: ${APISIX_MANAGER_INTERNAL_AUTH_TOKEN:?APISIX_MANAGER_INTERNAL_AUTH_TOKEN is required} + APISIX_MANAGER_PORT: "8141" depends_on: postgres: condition: service_healthy @@ -931,10 +932,8 @@ services: dockerfile: Dockerfile container_name: ndsep-lakehouse-writer environment: + LAKEHOUSE_WRITER_PORT: "8305" DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - S3_ENDPOINT: ${S3_ENDPOINT:-https://s3.amazonaws.com} - S3_BUCKET: ${S3_BUCKET:-ndsep-lakehouse} - PORT: "8305" depends_on: postgres: condition: service_healthy diff --git a/docker-compose.middleware.yml b/docker-compose.middleware.yml index fc4702ce..82a200f7 100644 --- a/docker-compose.middleware.yml +++ b/docker-compose.middleware.yml @@ -147,12 +147,19 @@ services: keycloak: image: quay.io/keycloak/keycloak:24.0.1 networks: [ndsep-net] - command: start-dev --import-realm + command: start --import-realm ports: ["8180:8080"] environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: ndsep_admin_2026 - KC_DB: dev-mem + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?KEYCLOAK_ADMIN_USER is required} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?KEYCLOAK_ADMIN_PASSWORD is required} + KC_DB: postgres + KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required} + KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME:?KEYCLOAK_DB_USERNAME is required} + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?KEYCLOAK_DB_PASSWORD is required} + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:?KEYCLOAK_HOSTNAME is required} + KC_HOSTNAME_STRICT: "true" + KC_PROXY_HEADERS: xforwarded + KC_HTTP_ENABLED: "true" KC_HEALTH_ENABLED: "true" healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/health/ready || exit 1"] @@ -167,7 +174,8 @@ services: ports: ["3476:3476", "3478:3478"] command: serve environment: - PERMIFY_DATABASE_ENGINE: memory + PERMIFY_DATABASE_ENGINE: postgres + PERMIFY_DATABASE_URI: ${PERMIFY_DATABASE_URI:?PERMIFY_DATABASE_URI is required} PERMIFY_LOG_LEVEL: warn healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3476/healthz"] @@ -328,8 +336,11 @@ services: networks: [ndsep-net] ports: ["8153:8153"] environment: - PORT: "8153" - APISIX_ADMIN_URL: http://apisix:9180 + APISIX_MANAGER_PORT: "8153" + WORKER_DATABASE_URL: ${WORKER_DATABASE_URL:?WORKER_DATABASE_URL is required} + APISIX_ADMIN_URL: ${APISIX_ADMIN_URL:?APISIX_ADMIN_URL is required} + APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:?APISIX_ADMIN_KEY is required} + APISIX_MANAGER_INTERNAL_AUTH_TOKEN: ${APISIX_MANAGER_INTERNAL_AUTH_TOKEN:?APISIX_MANAGER_INTERNAL_AUTH_TOKEN is required} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8153/health"] diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 38b4312e..63762f81 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -30,7 +30,6 @@ x-resource-defaults: &resource-defaults cpus: "0.1" memory: 128m - x-healthcheck-defaults: &hc-defaults interval: 30s timeout: 10s @@ -38,7 +37,6 @@ x-healthcheck-defaults: &hc-defaults start_period: 30s services: - # ─── NDSEP API (Node.js/Express/tRPC) ─────────────────────────────────────── ndsep-api: <<: [*resource-defaults, *default-restart] @@ -237,7 +235,11 @@ services: volumes: - kafka-data:/var/lib/kafka/data healthcheck: - test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"] + test: + [ + "CMD-SHELL", + "kafka-broker-api-versions --bootstrap-server localhost:9092", + ] interval: 30s timeout: 15s retries: 5 @@ -263,7 +265,8 @@ services: TEMPORAL_ADDRESS: temporal:7233 LOG_LEVEL: warn healthcheck: - test: ["CMD", "temporal", "workflow", "list", "--address", "temporal:7233"] + test: + ["CMD", "temporal", "workflow", "list", "--address", "temporal:7233"] interval: 30s timeout: 15s retries: 5 @@ -295,7 +298,8 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:8080/health/ready || exit 1"] + test: + ["CMD-SHELL", "curl -f http://localhost:8080/health/ready || exit 1"] interval: 30s timeout: 15s retries: 5 @@ -427,7 +431,16 @@ services: cap_drop: - ALL healthcheck: - test: ["CMD", "opa", "eval", "--format=raw", "--data", "/policies", "data.ndsep.authz.allow"] + test: + [ + "CMD", + "opa", + "eval", + "--format=raw", + "--data", + "/policies", + "data.ndsep.authz.allow", + ] <<: *hc-defaults logging: *default-logging networks: @@ -466,7 +479,7 @@ services: - "--web.external-url=http://alertmanager:9093" - "--log.level=warn" volumes: - - ./infra/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - ./.ndsep-runtime/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - ./infra/prometheus/templates:/etc/alertmanager/templates:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:9093/-/healthy"] @@ -475,12 +488,12 @@ services: networks: - ndsep-internal environment: - SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-https://hooks.slack.com/services/PLACEHOLDER} - PAGERDUTY_INTEGRATION_KEY: ${PAGERDUTY_INTEGRATION_KEY:-PLACEHOLDER_KEY} - NITDA_COMPLIANCE_EMAIL: ${NITDA_COMPLIANCE_EMAIL:-compliance@nitda.gov.ng} - PLATFORM_URL: ${PLATFORM_URL:-https://ndsep.nitda.gov.ng} - SMTP_HOST: ${SMTP_HOST:-smtp.gmail.com:587} - SMTP_FROM: ${SMTP_FROM:-ndsep-alerts@nitda.gov.ng} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:?SLACK_WEBHOOK_URL is required} + PAGERDUTY_INTEGRATION_KEY: ${PAGERDUTY_INTEGRATION_KEY:?PAGERDUTY_INTEGRATION_KEY is required} + NITDA_COMPLIANCE_EMAIL: ${NITDA_COMPLIANCE_EMAIL:?NITDA_COMPLIANCE_EMAIL is required} + PLATFORM_URL: ${PLATFORM_URL:?PLATFORM_URL is required} + SMTP_HOST: ${SMTP_HOST:?SMTP_HOST is required} + SMTP_FROM: ${SMTP_FROM:?SMTP_FROM is required} # ─── Grafana ────────────────────────────────────────────────────────────────── grafana: @@ -495,6 +508,7 @@ services: volumes: - grafana-data:/var/lib/grafana - ./infra/grafana/provisioning:/etc/grafana/provisioning:ro + - ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro depends_on: - prometheus healthcheck: @@ -862,7 +876,7 @@ services: - ndsep-public - ndsep-internal -# ─── Additional Go Workers ──────────────────────────────────────────────────── + # ─── Additional Go Workers ──────────────────────────────────────────────────── anomaly-alert-dispatcher: <<: [*resource-defaults, *default-restart] build: @@ -898,9 +912,10 @@ services: container_name: ndsep-apisix-manager environment: WORKER_DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - APISIX_ADMIN_URL: ${APISIX_ADMIN_URL:-http://apisix:9180} - APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:-edd1c9f034335f136f87ad84b625c8f1} - PORT: "8141" + APISIX_ADMIN_URL: ${APISIX_ADMIN_URL:?APISIX_ADMIN_URL is required} + APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:?APISIX_ADMIN_KEY is required} + APISIX_MANAGER_INTERNAL_AUTH_TOKEN: ${APISIX_MANAGER_INTERNAL_AUTH_TOKEN:?APISIX_MANAGER_INTERNAL_AUTH_TOKEN is required} + APISIX_MANAGER_PORT: "8141" depends_on: postgres: condition: service_healthy @@ -1309,7 +1324,7 @@ services: environment: WORKER_DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db NETBOX_URL: ${NETBOX_URL:-http://netbox:8080} - NETBOX_TOKEN: ${NETBOX_TOKEN:-0123456789abcdef0123456789abcdef01234567} + NETBOX_TOKEN: ${NETBOX_TOKEN:?NETBOX_TOKEN is required} PORT: "8159" depends_on: postgres: @@ -1420,9 +1435,9 @@ services: container_name: ndsep-ranger-policy environment: WORKER_DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - RANGER_URL: ${RANGER_URL:-http://ranger:6080} - RANGER_ADMIN_USER: ${RANGER_ADMIN_USER:-admin} - RANGER_ADMIN_PASS: ${RANGER_ADMIN_PASS:-rangerR0cks!} + RANGER_URL: ${RANGER_URL:?RANGER_URL is required} + RANGER_ADMIN_USER: ${RANGER_ADMIN_USER:?RANGER_ADMIN_USER is required} + RANGER_ADMIN_PASS: ${RANGER_ADMIN_PASS:?RANGER_ADMIN_PASS is required} PORT: "8164" depends_on: postgres: @@ -1478,7 +1493,7 @@ services: networks: - ndsep-internal -# ─── Orchestration Go Services ──────────────────────────────────────────────── + # ─── Orchestration Go Services ──────────────────────────────────────────────── dpco-api-gateway: <<: [*resource-defaults, *default-restart] build: @@ -1490,7 +1505,7 @@ services: environment: WORKER_DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db REDIS_URL: redis://redis:6379 - JWT_SECRET: ${JWT_SECRET:-ndsep-jwt-secret-change-in-production} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} PORT: "8200" depends_on: postgres: @@ -1658,7 +1673,7 @@ services: networks: - ndsep-internal -# ─── Rust Workers ───────────────────────────────────────────────────────────── + # ─── Rust Workers ───────────────────────────────────────────────────────────── bgp-validator: <<: [*resource-defaults, *default-restart] build: @@ -1687,7 +1702,7 @@ services: container_name: ndsep-evidence-signer environment: DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - SIGNING_KEY: ${EVIDENCE_SIGNING_KEY:-ndsep-evidence-signing-key-change-in-prod} + SIGNING_KEY: ${EVIDENCE_SIGNING_KEY:?EVIDENCE_SIGNING_KEY is required} PORT: "8301" depends_on: postgres: @@ -1747,11 +1762,13 @@ services: dockerfile: Dockerfile container_name: ndsep-lakehouse-ingest environment: + NODE_ENV: production + LAKEHOUSE_INGEST_PORT: "8304" DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - KAFKA_BROKERS: kafka:9092 - S3_ENDPOINT: ${S3_ENDPOINT:-https://s3.amazonaws.com} - S3_BUCKET: ${S3_BUCKET:-ndsep-lakehouse} - PORT: "8304" + POSTGRES_TLS_CA_FILE: /etc/ndsep/postgres-ca.crt + LAKEHOUSE_URL: ${LAKEHOUSE_INGEST_URL:?LAKEHOUSE_INGEST_URL is required} + volumes: + - ./infra/postgres/server.crt:/etc/ndsep/postgres-ca.crt:ro depends_on: postgres: condition: service_healthy @@ -1769,10 +1786,12 @@ services: dockerfile: Dockerfile container_name: ndsep-lakehouse-writer environment: + NODE_ENV: production + LAKEHOUSE_WRITER_PORT: "8305" DATABASE_URL: postgresql://ndsep_user:${POSTGRES_PASSWORD}@postgres:5432/ndsep_db - S3_ENDPOINT: ${S3_ENDPOINT:-https://s3.amazonaws.com} - S3_BUCKET: ${S3_BUCKET:-ndsep-lakehouse} - PORT: "8305" + POSTGRES_TLS_CA_FILE: /etc/ndsep/postgres-ca.crt + volumes: + - ./infra/postgres/server.crt:/etc/ndsep/postgres-ca.crt:ro depends_on: postgres: condition: service_healthy @@ -1978,6 +1997,6 @@ volumes: networks: ndsep-internal: driver: bridge - internal: true # No external access to internal services + internal: true # No external access to internal services ndsep-public: - driver: bridge # Only the Caddy edge connects this network to the internal service mesh + driver: bridge # Only the Caddy edge connects this network to the internal service mesh diff --git a/docs/security/GO_LIVE_EVIDENCE_REGISTER.md b/docs/security/GO_LIVE_EVIDENCE_REGISTER.md new file mode 100644 index 00000000..abf5c6b3 --- /dev/null +++ b/docs/security/GO_LIVE_EVIDENCE_REGISTER.md @@ -0,0 +1,77 @@ +# NDSEP 26-to-95 Go-Live Evidence Register + +**Date:** 2026-08-30 (EDT) +**Scope:** The 11 requested core services, cross-cutting evidence architecture, and new source-controlled hardening on PR #18. +**Decision status:** **No current Go-Live approval.** This is a measurable evidence register. It is not a claim that a score of 95 has been attained or that a regulator has accepted the platform. + +> A 95/100 readiness score requires independently demonstrated, production-like operation of every critical control; a clean directly scanned release image; a verified residency-evidence chain; a tested disaster-recovery exercise; and signed business/regulatory approvals. A source-code change alone cannot meet this threshold. + +## Scoring and gate rule + +A component can reach **95/100** only when it meets all of the following: secure multi-node/HA topology; private network and workload identity; encrypted transport and storage; least-privilege access; independently tested backup/restore or reconciliation; source-to-destination audit/evidence lineage; service-specific failure-mode test; and signed owner/compliance acceptance. Any unwaived HIGH/CRITICAL vulnerability in the deployed image, a default credential, a mutable artifact reference, a fail-open authorization path, or an untested disaster-recovery plan caps the related domain below 80. + +| Milestone | Gate | Evidence owner | Evidence required | +|---|---|---|---| +| M0 — Freeze unsafe release | Current digest blocked | Release security | Immutable direct Trivy evidence for `sha256:e1d…` and deployment deny record | +| M1 — Secure candidate | Final image contains no unwaived HIGH/CRITICAL finding | Platform security | Candidate digest, offline/direct Trivy JSON, SPDX SBOM, Cosign/DSSE attestations | +| M2 — Staging integration | Critical dependency transactions succeed and unsafe paths fail closed | Service owner + SRE | Repeatable test report, signed logs, dashboard integrity status | +| M3 — Resilience | Failover/restore/rotation drills complete | SRE + security | RTO/RPO measurement, recovery logs, reconciliation hash/ledger proof | +| M4 — Operating approval | Human/regulatory governance accepts the proven scope | Security + compliance | Approved change, risk decision, pilot/production authority (where applicable) | + +## 11 core service close-out plan + +| Core service | Current evidence-based state | Required implementation/remediation | Minimum Go-Live proof | Score ceiling until proven | +|---|---|---|---|---:| +| **1. PostgreSQL** | Primary store with RLS patterns; supervisory ledger amendment is not applied to staging. | Apply append-only hash-recomputation migration; TLS/mTLS; separate migration, ingest, projection and reporter roles; encrypted PITR/replica; connection pooling and statement controls; remove shared/default compose access. | Restore an encrypted backup to isolated staging; run advisory-lock/chain test; prove forced RLS and direct-DML denial; reconcile backup head against signed ledger head. | 70 | +| **2. TigerBeetle** | Real Go client path; one-node development topology. | 3+ replica topology; mTLS/workload identity; deterministic transfer-id/idempotency and reject-code handling; account/currency governance; operational reconciliation to PostgreSQL evidence ledger. | Failure/restart/duplicate-transfer tests; balance and journal reconciliation proof; approved non-production payment connector contract. | 65 | +| **3. Redis** | Used for rate limits/replay but originally standalone/plaintext/defaultable. PR #18 makes production replay enforcement depend on `rediss://`. | TLS/private CA; ACL per workload; Sentinel/cluster; encrypted backups/AOF test; resource limit/eviction policy; no public listener; rotate credentials. | Node failover during nonce/replay test; ACL denial test; restore and eviction test; readiness fails closed when unavailable. | 70 | +| **4. Mojaloop** | Transfer-shaped facade only; no verified participant deployment. | Do not call production endpoints. Establish authorized non-production participant/onboarding; mTLS/OAuth/JWS; asynchronous transfer state machine; signed callbacks; idempotency and settlement/reconciliation evidence. | Sandbox certification tests with signed callback and duplicate/out-of-order cases; settlement reconciliation; written partner approval. | 50 | +| **5. Kafka** | Single/plaintext development broker and HTTP proxy semantics. | KRaft multi-broker/zone topology; TLS/SASL/ACL; pre-created schema-governed topics; transactional outbox; idempotent producer; encryption/retention; DLQ and replay protection. | Broker loss/rebalance/lag/SLO test; ACL deny test; schema incompatibility rejection; outbox exactly-once/invariant reconciliation. | 70 | +| **6. APISIX** | Route synchronizer source exists; PR #18 removes embedded admin-key fallback and rejects non-HTTPS production Admin API. | HA gateway; private Admin API mTLS; short-lived workload credentials; GitOps-signed route configuration; OPA/ext-authz policy; rate limits/WAF/TLS; no public admin plane. | Route provenance verification; policy-denial and gateway-failover tests; admin credential rotation; configuration-drift evidence. | 70 | +| **7. Keycloak** | Wrapper/realm exists but `start-dev`, static credentials and plaintext topology remain. | HA production mode; external PostgreSQL; HTTPS; admin bootstrap rotation; realm/client key rotation; group/claim mapping coverage; mTLS for confidential clients; tested session/revocation policy. | Key rotation/revocation test; HA loss test; tenant/role authorization matrix; no default admin credential scan. | 70 | +| **8. open-appsec** | Adapter/worker reference, not a verified enforcement deployment. | Deploy supported WAF/integration in monitor mode first; signed policy lifecycle; traffic scope and privacy review; protected management identity; logging to immutable audit stream; controlled block-mode change. | Positive/negative attack corpus; false-positive review; policy rollback drill; alert-to-case evidence linkage. | 55 | +| **9. Permify** | Direct client rejects on error; development compose path lacks secure/controlled schema lifecycle. | Pinned image; TLS/mTLS; service credential; versioned schema migration/approval; relation lifecycle/reconciliation; availability SLO; no `latest`. | Authorize/deny/timeout matrix; tenant isolation test; schema rollback; relationship reconciliation evidence. | 70 | +| **10. OpenSearch** | Indexer/query adapter performs HTTP calls but no secure cluster proof. | HA secure cluster; TLS/mTLS/API keys; index templates/ILM; tenant isolation; audit logging; snapshots/restore; index records linked to PostgreSQL evidence/event hash. | Search access deny test; snapshot restore; index-to-ledger hash correlation; node-loss/replica test. | 65 | +| **11. Fluvio** | Relay/interface only; no verified cluster, auth or retention controls. | Decide whether Fluvio is necessary alongside Kafka; if retained, deploy a supported secure cluster with mTLS/ACL/schema/retention/replay, edge connectivity and monitoring. Otherwise remove it and its claims. | Cluster/auth/replay/retention tests and production ownership decision; or verified dependency removal. | 50 | + +## Cross-cutting blockers that cannot be delegated to a service + +| Blocker | Required close-out condition | +|---|---| +| **Container security** | The candidate image is built once from pinned inputs, has an SBOM/provenance/signature, and direct Trivy scan of its **published digest** reports zero unwaived HIGH/CRITICAL runtime results. The current published digest remains blocked. | +| **Residency assertion** | Replace client-provided location with signed/cloud/database/backup/egress evidence, independent verifier policy, timestamp/nonce/replay validation, Merkle transparency proofs and an appeal workflow. Do not claim physical geography from an IP/region label alone. | +| **Worker events** | PR #18 now verifies HMAC, exact raw request bytes, freshness and Redis nonce reservation. Every sender must be migrated to the versioned protocol before production enablement; mTLS/workload identity should replace shared secrets for the next deployment phase. | +| **Evidence and reporting** | PostgreSQL integrity ledger, encrypted blobs, evidence verifier, registry/ledger proofs and recipient signed acknowledgement must be deployed/tested. Dashboards and SIEM copies are not evidence authorities. | +| **ML governance** | Synthetic candidate artifacts are real weights but not regulatory/policy models. A real-data route requires lawful data authority, privacy impact assessment, versioned/approved labels, time/cohort metrics, bias/drift tests, model registry, signing, deployment approval, rollback and human decision/appeal controls. | + +## PR #18 hardening delta + +PR #18 adds implementation evidence but does not close external dependencies: + +1. Removes the unused `@temporalio/worker` production dependency, eliminating its webpack/esbuild closure from a production-only dependency tree. +2. Pins the Node Alpine base by immutable manifest digest, upgrades base packages during build, pins pnpm, and copies a production-only deployment tree into the runtime image. The candidate must still be built and directly scanned. +3. Adds strict production startup checks for `rediss://`, explicit CORS origin allow-list and high-entropy worker-event HMAC configuration. +4. Adds HMAC-SHA-256, exact-body binding, timestamp freshness and Redis `SET NX PX` replay prevention to the worker event relay. Four focused tests pass locally. +5. Removes APISIX embedded admin-key fallback and requires an HTTPS Admin API in production. +6. Corrects audit-return output so a PDF cannot be labelled signed after a signing failure. +7. Adds real synthetic-only CPU PyTorch MLP/GraphSAGE training, signed artifacts and candidate-only inference; it does not create a regulated production model. + +## Go-Live decision evidence pack + +A request to change from **NO-GO** to a score of 95 must include, at minimum, a signed evidence manifest with the following files and owners: + +1. Candidate OCI digest, build provenance, SBOM, direct Trivy report, remediation trace and Cosign verification result — **release security**. +2. Service-by-service staging integration, fault, security and recovery evidence tied to immutable artifacts — **service owner/SRE**. +3. Residency evidence verification/appeal test records and policy version — **compliance/evidence verifier**. +4. Database backup/PITR, ledger chain, reporting submission and acknowledgement proof — **data owner/compliance**. +5. ML data/model approvals, reproducible training/evaluation, drift/bias results and decision controls — **model-risk owner**. +6. Explicit pilot/production authority from accountable business, security and legal/compliance owners; if CBN submission is involved, the actual authorized interface/recipient acknowledgement — **governance**. + +Until all evidence is independently reviewed and accepted, the defensible operational conclusion remains **NO-GO**, not 95/100. + +## References + +1. `/home/ubuntu/ndsep-production-readiness-and-ml-stack-assessment.md` — prior score and source review. +2. `/home/ubuntu/ndsep-image-scan/published-image-trivy-high-critical.md` — direct published-image scan baseline. +3. `/home/ubuntu/ndsep-residency-architecture-and-image-security-review.md` — residency/evidence limitations. +4. `/home/ubuntu/ndsep-repo/workers/python/ml_foundation/MODEL_CARD_SYNTHETIC_CANDIDATE.md` — synthetic candidate model boundary. diff --git a/docs/security/PR18_POST_HARDENING_READINESS_REASSESSMENT.md b/docs/security/PR18_POST_HARDENING_READINESS_REASSESSMENT.md new file mode 100644 index 00000000..91b45b71 --- /dev/null +++ b/docs/security/PR18_POST_HARDENING_READINESS_REASSESSMENT.md @@ -0,0 +1,53 @@ +# NDSEP PR #18 Post-Hardening Readiness Reassessment + +**Assessment date:** 2026-08-30 (EDT) +**Reviewed revision:** `27623f9edbe9b876db281773bff6b7e3e3a76f96` on PR #18 +**Conclusion:** **31/100 — NO-GO.** This is a source-and-local-validation reassessment, not a production certification or a CBN finding. + +## Verified hardening delta + +PR #18 adds meaningful, locally validated safeguards. The Dockerfile now uses a pinned Node Alpine base digest, a fixed pnpm version, package upgrades and a production-only dependency installation/copy step. Removing the unused `@temporalio/worker` direct production dependency removes the known Temporal → webpack → esbuild closure from the current `pnpm --prod` graph. This has not yet been proven on a rebuilt, published candidate digest; only a direct final-image scan can establish that the 42 HIGH/CRITICAL baseline is remediated. + +The worker relay now checks an HMAC-SHA-256 over the **exact received JSON bytes**, a bounded millisecond timestamp, worker identity and nonce. Production performs atomic Redis `SET NX PX` nonce reservation and rejects events if the nonce store is unavailable. Environment validation now rejects wildcard CORS, an absent/weak worker event secret and non-`rediss://` Redis in production. The APISIX synchronizer no longer has an embedded admin key, requires an explicit high-entropy key and refuses a non-HTTPS Admin API in production. Audit-return PDF responses now accurately disclose cryptographic signing failure rather than label an unsigned file as signed. + +| Validation | Result | +|---|---| +| Worker-event HMAC/freshness/tamper/schema tests | **4/4 passed** locally | +| TypeScript compiler | **Passed** locally | +| Diff whitespace validation | **Passed** locally | +| Fresh synthetic-only MLP + GraphSAGE CPU training | **Passed** locally | +| Candidate artifact hash/signature and CPU inference verification | **Passed** locally | +| Ray local two-CPU candidate tasks | **Completed** locally | +| Go APISIX compilation | **Not run locally**; Go toolchain absent in sandbox | +| New CPU Docker image build | **Not run locally**; Docker unavailable in sandbox | +| Final candidate image direct Trivy scan | **Not run**; no rebuilt published candidate digest | +| External-service staging acceptance / DR tests | **Not run**; no authorized environments/clusters | + +## Updated scoring calculation + +The scoring model remains: PostgreSQL 12%; identity/authorization 10%; security/delivery 13%; residency/evidence 20%; financial/enforcement 10%; event/integration mesh 15%; AI/data/analytics 15%; CBN reporting 5%. The updated values reflect only direct implementation and local evidence, not presumed operation. + +| Domain | Before | After | Rationale | Evidence ceiling | +|---|---:|---:|---|---:| +| PostgreSQL and data foundation | 45 | 45 | Schema packages remain un-applied/unproven in staging. | 70 | +| Identity and authorization | 40 | 40 | Keycloak/Permify external topology remains unverified. | 70 | +| Security and delivery | 24 | 35 | Pinned/minimized Docker source, mandatory production secrets and APISIX hardening; final image scan/staging delivery still absent. | 70 | +| Residency and evidence assurance | 10 | 10 | Self-attested/synthetic residency evidence remains unsuitable. | 40 | +| Financial/enforcement | 24 | 24 | TigerBeetle/Mojaloop/payment controls remain unproven. | 65 | +| Event/integration mesh | 28 | 32 | Relay now fails closed with signed/fresh/replay-safe ingress; every worker sender and Kafka/outbox topology are not yet migrated/proven. | 70 | +| AI/data/analytics | 28 | 45 | Real signed synthetic CPU candidate MLP/GNN and Ray tasks exist; no governed real data/labels or production MLOps. | 65 | +| CBN reporting | 15 | 15 | No official CBN transport/receipt/acknowledgement evidence. | 50 | +| **Weighted composite** | **26.07** | **30.65** | Rounded result is **31/100**. | **NO-GO** | + +## Conditions for a defensible 95/100 Go-Live decision + +No code-only activity can raise the platform to 95/100. The `GO_LIVE_EVIDENCE_REGISTER.md` committed in PR #18 specifies the required signed evidence. In short, the release owner must produce a clean published-digest scan/SBOM/provenance/signature set; each of the 11 services must pass secure topology, workload identity, failure/restore and reconciliation tests; the residency engine must use externally corroborated signed evidence rather than user inputs; CBN reporting must use an authorized interface with a verified recipient acknowledgement; and accountable engineering, security, data protection, legal/compliance and business owners must approve the measured production scope. + +> The newly trained MLP and GraphSAGE weights are **synthetic candidates only**. They are not trained or fine-tuned on platform, financial, citizen, CBN or other regulated data, and they must not drive automated enforcement, payment activity, residency conclusions, regulatory filings or adverse decisions. + +## References + +1. [Direct published-image scan baseline](/home/ubuntu/ndsep-image-scan/published-image-trivy-high-critical.md) +2. [Original readiness assessment](/home/ubuntu/ndsep-production-readiness-and-ml-stack-assessment.md) +3. [PR #18 Go-Live evidence register](/home/ubuntu/ndsep-repo/docs/security/GO_LIVE_EVIDENCE_REGISTER.md) +4. [Synthetic candidate model card](/home/ubuntu/ndsep-repo/workers/python/ml_foundation/MODEL_CARD_SYNTHETIC_CANDIDATE.md) diff --git a/drizzle/0035_digital_twin_noc_runtime_schema.sql b/drizzle/0035_digital_twin_noc_runtime_schema.sql new file mode 100644 index 00000000..4d453497 --- /dev/null +++ b/drizzle/0035_digital_twin_noc_runtime_schema.sql @@ -0,0 +1,417 @@ +-- Active Drizzle reconciliation for digital-twin and NOC runtime tables. +-- Derived from legacy migrations but intentionally contains schema/index DDL only. +-- No synthetic data, release evidence, or production operational records are inserted here. + +-- NOC runtime schema +-- ============================================================================ +-- NDSEP NOC AI Agent Schema +-- ============================================================================ +-- Tables for the autonomous AI agent system that proactively detects, +-- diagnoses, and remediates infrastructure issues in real-time. +-- +-- Architecture: +-- Perception (Rust) → Reasoning (Python/LLM) → Action (Go) → Learning +-- ============================================================================ + +-- ── Agent Memory: long-term knowledge store ────────────────────────────────── +CREATE TABLE IF NOT EXISTS noc_agent_memory ( + id SERIAL PRIMARY KEY, + memory_id VARCHAR(64) NOT NULL UNIQUE DEFAULT gen_random_uuid()::text, + memory_type VARCHAR(30) NOT NULL CHECK (memory_type IN ( + 'incident_pattern', 'root_cause', 'remediation_outcome', + 'service_baseline', 'topology_change', 'threshold_adjustment', + 'correlation_rule', 'runbook_improvement', 'false_positive', 'insight' + )), + category VARCHAR(50) NOT NULL DEFAULT 'general', + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + context JSONB NOT NULL DEFAULT '{}', + confidence NUMERIC(5,4) NOT NULL DEFAULT 0.5 CHECK (confidence >= 0 AND confidence <= 1), + usage_count INTEGER DEFAULT 0, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + source_incident_id VARCHAR(64), + tags TEXT[] DEFAULT '{}', + embedding_vector JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Incident Knowledge Graph ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS noc_incident_knowledge ( + id SERIAL PRIMARY KEY, + knowledge_id VARCHAR(64) NOT NULL UNIQUE DEFAULT gen_random_uuid()::text, + incident_type VARCHAR(50) NOT NULL, + symptom_signature JSONB NOT NULL DEFAULT '{}', + root_cause TEXT NOT NULL, + root_cause_category VARCHAR(50) NOT NULL CHECK (root_cause_category IN ( + 'hardware_failure', 'software_bug', 'configuration_error', + 'capacity_exhaustion', 'network_issue', 'security_incident', + 'dependency_failure', 'human_error', 'environmental', 'unknown' + )), + affected_services TEXT[] DEFAULT '{}', + affected_devices TEXT[] DEFAULT '{}', + remediation_steps JSONB NOT NULL DEFAULT '[]', + prevention_measures JSONB DEFAULT '[]', + avg_detection_time_seconds INTEGER, + avg_resolution_time_seconds INTEGER, + occurrence_count INTEGER DEFAULT 1, + last_occurrence TIMESTAMPTZ, + success_rate NUMERIC(5,4) DEFAULT 0.0, + severity_distribution JSONB DEFAULT '{"critical":0,"high":0,"medium":0,"low":0}', + related_knowledge_ids TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Agent Actions: autonomous remediation log ──────────────────────────────── +CREATE TABLE IF NOT EXISTS noc_agent_actions ( + id SERIAL PRIMARY KEY, + action_id VARCHAR(64) NOT NULL UNIQUE DEFAULT gen_random_uuid()::text, + agent_type VARCHAR(30) NOT NULL CHECK (agent_type IN ( + 'perception', 'reasoning', 'action', 'orchestrator' + )), + action_type VARCHAR(50) NOT NULL CHECK (action_type IN ( + 'anomaly_detected', 'root_cause_analyzed', 'remediation_planned', + 'remediation_executed', 'remediation_verified', 'escalated_to_human', + 'threshold_adjusted', 'runbook_updated', 'knowledge_created', + 'false_positive_marked', 'service_restarted', 'config_rolled_back', + 'traffic_rerouted', 'capacity_scaled', 'alert_suppressed', + 'correlation_discovered', 'prediction_made', 'baseline_updated' + )), + alert_id VARCHAR(64), + correlation_id VARCHAR(64), + device_id VARCHAR(64), + affected_service VARCHAR(100), + description TEXT NOT NULL, + input_data JSONB DEFAULT '{}', + output_data JSONB DEFAULT '{}', + confidence_score NUMERIC(5,4) NOT NULL DEFAULT 0.5, + was_auto_executed BOOLEAN DEFAULT false, + human_approved BOOLEAN, + execution_time_ms INTEGER, + outcome VARCHAR(20) CHECK (outcome IN ( + 'success', 'partial_success', 'failure', 'pending', 'skipped', 'rolled_back' + )), + outcome_details TEXT, + knowledge_id VARCHAR(64), + parent_action_id VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Remediation History: detailed execution records ────────────────────────── +CREATE TABLE IF NOT EXISTS noc_remediation_history ( + id SERIAL PRIMARY KEY, + remediation_id VARCHAR(64) NOT NULL UNIQUE DEFAULT gen_random_uuid()::text, + alert_id VARCHAR(64) NOT NULL, + correlation_id VARCHAR(64), + incident_type VARCHAR(50) NOT NULL, + severity VARCHAR(10) NOT NULL, + detection_method VARCHAR(30) NOT NULL CHECK (detection_method IN ( + 'anomaly_ml', 'threshold_breach', 'pattern_match', 'predictive', + 'correlation', 'user_reported', 'health_check' + )), + diagnosis JSONB NOT NULL DEFAULT '{}', + root_cause_hypothesis TEXT, + confidence_score NUMERIC(5,4) NOT NULL, + remediation_plan JSONB NOT NULL DEFAULT '[]', + steps_executed INTEGER DEFAULT 0, + steps_total INTEGER DEFAULT 0, + steps_succeeded INTEGER DEFAULT 0, + was_autonomous BOOLEAN DEFAULT false, + human_intervention_required BOOLEAN DEFAULT false, + human_intervention_reason TEXT, + time_to_detect_seconds INTEGER, + time_to_diagnose_seconds INTEGER, + time_to_remediate_seconds INTEGER, + time_to_verify_seconds INTEGER, + total_resolution_seconds INTEGER, + outcome VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (outcome IN ( + 'resolved', 'partially_resolved', 'failed', 'escalated', 'pending', 'rolled_back' + )), + lessons_learned TEXT, + knowledge_updates JSONB DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Service Baselines: learned normal behavior per service ─────────────────── +CREATE TABLE IF NOT EXISTS noc_service_baselines ( + id SERIAL PRIMARY KEY, + service_name VARCHAR(100) NOT NULL, + metric_name VARCHAR(50) NOT NULL, + baseline_period VARCHAR(20) DEFAULT '7d', + mean_value NUMERIC(12,4) NOT NULL, + std_deviation NUMERIC(12,4) NOT NULL, + p50_value NUMERIC(12,4), + p95_value NUMERIC(12,4), + p99_value NUMERIC(12,4), + min_value NUMERIC(12,4), + max_value NUMERIC(12,4), + sample_count INTEGER NOT NULL DEFAULT 0, + anomaly_threshold_sigma NUMERIC(4,2) DEFAULT 3.0, + last_anomaly_at TIMESTAMPTZ, + anomaly_count INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(service_name, metric_name, baseline_period) +); + +-- ── Agent Predictions: proactive issue forecasting ─────────────────────────── +CREATE TABLE IF NOT EXISTS noc_agent_predictions ( + id SERIAL PRIMARY KEY, + prediction_id VARCHAR(64) NOT NULL UNIQUE DEFAULT gen_random_uuid()::text, + prediction_type VARCHAR(30) NOT NULL CHECK (prediction_type IN ( + 'capacity_exhaustion', 'service_degradation', 'security_threat', + 'sla_breach', 'hardware_failure', 'network_congestion', + 'dependency_failure', 'performance_regression' + )), + affected_service VARCHAR(100), + affected_device VARCHAR(64), + predicted_event TEXT NOT NULL, + predicted_time TIMESTAMPTZ, + confidence_score NUMERIC(5,4) NOT NULL, + evidence JSONB NOT NULL DEFAULT '{}', + recommended_actions JSONB DEFAULT '[]', + was_accurate BOOLEAN, + actual_event_time TIMESTAMPTZ, + preventive_action_taken BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Indexes ────────────────────────────────────────────────────────────────── + +CREATE INDEX IF NOT EXISTS idx_agent_memory_type ON noc_agent_memory(memory_type); +CREATE INDEX IF NOT EXISTS idx_agent_memory_category ON noc_agent_memory(category); +CREATE INDEX IF NOT EXISTS idx_agent_memory_confidence ON noc_agent_memory(confidence DESC); +CREATE INDEX IF NOT EXISTS idx_agent_memory_tags ON noc_agent_memory USING gin(tags); + +CREATE INDEX IF NOT EXISTS idx_incident_knowledge_type ON noc_incident_knowledge(incident_type); +CREATE INDEX IF NOT EXISTS idx_incident_knowledge_category ON noc_incident_knowledge(root_cause_category); +CREATE INDEX IF NOT EXISTS idx_incident_knowledge_success ON noc_incident_knowledge(success_rate DESC); + +CREATE INDEX IF NOT EXISTS idx_agent_actions_type ON noc_agent_actions(action_type); +CREATE INDEX IF NOT EXISTS idx_agent_actions_agent ON noc_agent_actions(agent_type); +CREATE INDEX IF NOT EXISTS idx_agent_actions_alert ON noc_agent_actions(alert_id); +CREATE INDEX IF NOT EXISTS idx_agent_actions_auto ON noc_agent_actions(was_auto_executed); +CREATE INDEX IF NOT EXISTS idx_agent_actions_created ON noc_agent_actions(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_remediation_alert ON noc_remediation_history(alert_id); +CREATE INDEX IF NOT EXISTS idx_remediation_outcome ON noc_remediation_history(outcome); +CREATE INDEX IF NOT EXISTS idx_remediation_autonomous ON noc_remediation_history(was_autonomous); +CREATE INDEX IF NOT EXISTS idx_remediation_created ON noc_remediation_history(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_baselines_service ON noc_service_baselines(service_name); +CREATE INDEX IF NOT EXISTS idx_predictions_type ON noc_agent_predictions(prediction_type); +CREATE INDEX IF NOT EXISTS idx_predictions_confidence ON noc_agent_predictions(confidence_score DESC); +CREATE INDEX IF NOT EXISTS idx_predictions_time ON noc_agent_predictions(predicted_time); + +-- Digital-twin runtime schema +-- Digital Twin V2: Production-grade multi-government policy simulation +-- Persistence, jurisdictions, policies, agent-based models, economic indicators + +-- ── Jurisdictions ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_jurisdictions ( + id SERIAL PRIMARY KEY, + code VARCHAR(10) NOT NULL UNIQUE, -- NG, GH, KE, ZA, EU + name VARCHAR(100) NOT NULL, + region VARCHAR(50) NOT NULL, -- West Africa, East Africa, Southern Africa, Europe + data_protection_act VARCHAR(100), -- NDPA, DPA 2012, DPA 2019, POPIA, GDPR + regulator VARCHAR(100), -- NDPC, DPC Ghana, ODPC Kenya, InfoReg, EDPB + adequacy_status VARCHAR(20) DEFAULT 'none', -- none, partial, full, mutual + population_millions NUMERIC(10,2), + gdp_usd_billions NUMERIC(12,2), + digital_economy_pct NUMERIC(5,2), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ── Policy Definitions (PDL) ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_policies ( + id SERIAL PRIMARY KEY, + jurisdiction_id INTEGER REFERENCES dt_jurisdictions(id), + code VARCHAR(50) NOT NULL, -- e.g., NDPA-BREACH-SLA-2025 + name VARCHAR(200) NOT NULL, + category VARCHAR(50) NOT NULL, -- breach_notification, penalties, data_transfer, consent, children_data + status VARCHAR(20) DEFAULT 'draft', -- draft, proposed, enacted, enforced, repealed + effective_date DATE, + expiry_date DATE, + rules JSONB NOT NULL DEFAULT '[]', -- [{target_sector, metric, operator, threshold, penalty_formula}] + parameters JSONB NOT NULL DEFAULT '{}', -- adjustable params for simulation + dependencies JSONB DEFAULT '[]', -- policy IDs this depends on + conflicts_with JSONB DEFAULT '[]', -- policy IDs this conflicts with + created_by VARCHAR(100) DEFAULT 'system', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_dt_policies_jurisdiction ON dt_policies(jurisdiction_id); +CREATE INDEX IF NOT EXISTS idx_dt_policies_category ON dt_policies(category); +CREATE INDEX IF NOT EXISTS idx_dt_policies_status ON dt_policies(status); + +-- ── Sector Models (per-jurisdiction) ─────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_sector_models ( + id SERIAL PRIMARY KEY, + jurisdiction_id INTEGER REFERENCES dt_jurisdictions(id), + sector VARCHAR(50) NOT NULL, + organizations INTEGER NOT NULL DEFAULT 0, + avg_compliance NUMERIC(5,2) NOT NULL DEFAULT 50.0, + breach_rate NUMERIC(5,4) NOT NULL DEFAULT 0.10, + avg_penalty_local NUMERIC(15,2) DEFAULT 0, + avg_budget_usd NUMERIC(12,2) DEFAULT 0, -- avg security budget per org + staff_count_avg INTEGER DEFAULT 0, -- avg infosec staff per org + tech_maturity NUMERIC(3,1) DEFAULT 5.0, -- 1-10 scale + data_volume_gb NUMERIC(12,2) DEFAULT 0, + cross_border_pct NUMERIC(5,2) DEFAULT 0, + risk_factors JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(jurisdiction_id, sector) +); +CREATE INDEX IF NOT EXISTS idx_dt_sector_models_jurisdiction ON dt_sector_models(jurisdiction_id); + +-- ── Organization Agents (ABM) ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_org_agents ( + id SERIAL PRIMARY KEY, + jurisdiction_id INTEGER REFERENCES dt_jurisdictions(id), + org_name VARCHAR(200) NOT NULL, + sector VARCHAR(50) NOT NULL, + compliance_score NUMERIC(5,2) DEFAULT 50.0, + security_budget_usd NUMERIC(12,2) DEFAULT 0, + infosec_staff INTEGER DEFAULT 0, + tech_maturity NUMERIC(3,1) DEFAULT 5.0, + risk_appetite NUMERIC(3,1) DEFAULT 5.0, -- 1=risk-averse, 10=risk-seeking + breach_history INTEGER DEFAULT 0, -- past breach count + penalty_history NUMERIC(15,2) DEFAULT 0, -- total penalties paid + data_volume_gb NUMERIC(12,2) DEFAULT 0, + cross_border BOOLEAN DEFAULT FALSE, + last_audit_date DATE, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_dt_org_agents_jurisdiction ON dt_org_agents(jurisdiction_id); +CREATE INDEX IF NOT EXISTS idx_dt_org_agents_sector ON dt_org_agents(sector); + +-- ── Simulation Runs ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_simulations ( + id SERIAL PRIMARY KEY, + simulation_id VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(200), + type VARCHAR(30) NOT NULL, -- scenario, monte_carlo, counterfactual, sandbox, policy_test + jurisdictions JSONB NOT NULL DEFAULT '["NG"]', + policies JSONB NOT NULL DEFAULT '[]', -- policy IDs applied + parameters JSONB NOT NULL DEFAULT '{}', + duration_months INTEGER DEFAULT 12, + iterations INTEGER DEFAULT 1, -- for Monte Carlo + status VARCHAR(20) DEFAULT 'pending', -- pending, running, completed, failed + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_by VARCHAR(100) DEFAULT 'system', + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_dt_simulations_type ON dt_simulations(type); +CREATE INDEX IF NOT EXISTS idx_dt_simulations_status ON dt_simulations(status); + +-- ── Simulation Results (time series) ─────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_simulation_results ( + id SERIAL PRIMARY KEY, + simulation_id VARCHAR(100) REFERENCES dt_simulations(simulation_id), + jurisdiction VARCHAR(10) NOT NULL, + month INTEGER NOT NULL, + iteration INTEGER DEFAULT 1, -- for Monte Carlo + avg_compliance NUMERIC(5,2), + breach_count INTEGER, + total_penalties_local NUMERIC(15,2), + cross_border_flows INTEGER, + gdp_impact_pct NUMERIC(5,3), + fdi_confidence NUMERIC(5,2), -- 0-100 + insurance_cost_idx NUMERIC(5,2), -- index vs baseline + sector_data JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_dt_sim_results_sim ON dt_simulation_results(simulation_id); +CREATE INDEX IF NOT EXISTS idx_dt_sim_results_jurisdiction ON dt_simulation_results(jurisdiction); + +-- ── Monte Carlo Aggregates ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_monte_carlo_stats ( + id SERIAL PRIMARY KEY, + simulation_id VARCHAR(100) REFERENCES dt_simulations(simulation_id), + jurisdiction VARCHAR(10) NOT NULL, + month INTEGER NOT NULL, + metric VARCHAR(50) NOT NULL, -- compliance, breaches, penalties, gdp_impact + p5 NUMERIC(15,4), + p25 NUMERIC(15,4), + p50 NUMERIC(15,4), + p75 NUMERIC(15,4), + p95 NUMERIC(15,4), + mean NUMERIC(15,4), + std_dev NUMERIC(15,4), + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_dt_mc_stats_sim ON dt_monte_carlo_stats(simulation_id); + +-- ── Policy Impact Analysis ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_policy_impacts ( + id SERIAL PRIMARY KEY, + simulation_id VARCHAR(100) REFERENCES dt_simulations(simulation_id), + policy_id INTEGER REFERENCES dt_policies(id), + jurisdiction VARCHAR(10) NOT NULL, + sector VARCHAR(50), -- NULL = all sectors + compliance_delta NUMERIC(5,2), + breach_delta_pct NUMERIC(5,2), + penalty_delta_local NUMERIC(15,2), + cost_benefit_ratio NUMERIC(8,4), -- benefit per unit cost + effectiveness_score NUMERIC(5,2), -- 0-100 + sensitivity_rank INTEGER, -- which param matters most + recommendations JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_dt_policy_impacts_sim ON dt_policy_impacts(simulation_id); +CREATE INDEX IF NOT EXISTS idx_dt_policy_impacts_policy ON dt_policy_impacts(policy_id); + +-- ── Economic Indicators ──────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_economic_indicators ( + id SERIAL PRIMARY KEY, + jurisdiction VARCHAR(10) NOT NULL, + year INTEGER NOT NULL, + quarter INTEGER NOT NULL CHECK (quarter BETWEEN 1 AND 4), + gdp_usd_billions NUMERIC(12,2), + digital_economy_usd_billions NUMERIC(12,2), + fdi_inflow_usd_billions NUMERIC(12,2), + cyber_insurance_premium_idx NUMERIC(5,2), -- index 100 = baseline + data_breach_cost_avg_usd NUMERIC(12,2), + compliance_spending_usd_millions NUMERIC(12,2), + data_localization_cost_usd_millions NUMERIC(12,2), + cross_border_trade_volume_usd_billions NUMERIC(12,2), + source VARCHAR(50) DEFAULT 'synthetic', -- synthetic, cbn, nbs, worldbank + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(jurisdiction, year, quarter) +); +CREATE INDEX IF NOT EXISTS idx_dt_econ_jurisdiction ON dt_economic_indicators(jurisdiction); + +-- ── Regulatory Sandbox ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_sandboxes ( + id SERIAL PRIMARY KEY, + sandbox_id VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + description TEXT, + base_snapshot JSONB NOT NULL, -- frozen state at fork time + policies_applied JSONB DEFAULT '[]', + status VARCHAR(20) DEFAULT 'active', -- active, completed, archived + created_by VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +-- ── Cross-Border Policy Agreements ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dt_bilateral_agreements ( + id SERIAL PRIMARY KEY, + jurisdiction_a VARCHAR(10) NOT NULL, + jurisdiction_b VARCHAR(10) NOT NULL, + agreement_type VARCHAR(50) NOT NULL, -- adequacy, mutual_recognition, data_sharing, joint_enforcement + status VARCHAR(20) DEFAULT 'active', + signed_date DATE, + provisions JSONB DEFAULT '{}', + impact_on_flows NUMERIC(5,2) DEFAULT 0, -- % change in cross-border volume + created_at TIMESTAMPTZ DEFAULT NOW() +); diff --git a/drizzle/0036_dpco_analytics_durable_events.sql b/drizzle/0036_dpco_analytics_durable_events.sql new file mode 100644 index 00000000..d665b852 --- /dev/null +++ b/drizzle/0036_dpco_analytics_durable_events.sql @@ -0,0 +1,75 @@ +-- Durable source-of-truth storage for the DPCO analytics Python microservice. +-- This migration is schema-only. It does not seed demo data or create compliance evidence. + +CREATE TABLE IF NOT EXISTS dpco_analytics_events ( + id UUID PRIMARY KEY, + event_type VARCHAR(128) NOT NULL, + dpco_id VARCHAR(128) NOT NULL, + source VARCHAR(32) NOT NULL CHECK (source IN ('api', 'kafka', 'dapr')), + occurred_at TIMESTAMPTZ NOT NULL, + ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + compliance_score NUMERIC(6,2), + to_stage VARCHAR(128), + payload JSONB NOT NULL, + payload_sha256 CHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT dpco_analytics_events_score_range CHECK ( + compliance_score IS NULL OR (compliance_score >= 0 AND compliance_score <= 100) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_dpco_analytics_events_source_payload + ON dpco_analytics_events (source, payload_sha256); +CREATE INDEX IF NOT EXISTS idx_dpco_analytics_events_dpco_occurred + ON dpco_analytics_events (dpco_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_dpco_analytics_events_type_occurred + ON dpco_analytics_events (event_type, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_dpco_analytics_events_occurred + ON dpco_analytics_events (occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_dpco_analytics_events_payload_gin + ON dpco_analytics_events USING gin (payload); + +CREATE TABLE IF NOT EXISTS dpco_analytics_dpco_stats ( + dpco_id VARCHAR(128) PRIMARY KEY, + audits_initiated INTEGER NOT NULL DEFAULT 0 CHECK (audits_initiated >= 0), + audits_completed INTEGER NOT NULL DEFAULT 0 CHECK (audits_completed >= 0), + statements_issued INTEGER NOT NULL DEFAULT 0 CHECK (statements_issued >= 0), + score_total NUMERIC(18,4) NOT NULL DEFAULT 0, + score_count INTEGER NOT NULL DEFAULT 0 CHECK (score_count >= 0), + sla_breaches INTEGER NOT NULL DEFAULT 0 CHECK (sla_breaches >= 0), + last_activity TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_dpco_analytics_dpco_stats_last_activity + ON dpco_analytics_dpco_stats (last_activity DESC NULLS LAST); + +-- Transactional outbox for durable Kafka/Dapr delivery from server domain mutations. +CREATE TABLE IF NOT EXISTS domain_event_outbox ( + id UUID PRIMARY KEY, + event_type VARCHAR(128) NOT NULL, + topic VARCHAR(255) NOT NULL, + aggregate_id VARCHAR(255) NOT NULL, + aggregate_type VARCHAR(128) NOT NULL, + payload JSONB NOT NULL, + headers JSONB NOT NULL DEFAULT '{}', + user_id INTEGER, + correlation_id VARCHAR(128) NOT NULL UNIQUE, + status VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'publishing', 'published')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + lease_expires_at TIMESTAMPTZ, + last_error TEXT, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_domain_event_outbox_due + ON domain_event_outbox (status, next_attempt_at, created_at); +CREATE INDEX IF NOT EXISTS idx_domain_event_outbox_lease + ON domain_event_outbox (status, lease_expires_at) + WHERE status = 'publishing'; +CREATE INDEX IF NOT EXISTS idx_domain_event_outbox_aggregate + ON domain_event_outbox (aggregate_type, aggregate_id, created_at DESC); diff --git a/drizzle/0037_dpco_client_policy_postgres_reconciliation.sql b/drizzle/0037_dpco_client_policy_postgres_reconciliation.sql new file mode 100644 index 00000000..9f05beb4 --- /dev/null +++ b/drizzle/0037_dpco_client_policy_postgres_reconciliation.sql @@ -0,0 +1,22 @@ +-- DPCO client-policy PostgreSQL reconciliation. +-- This migration is intentionally DDL-only. It fails closed when legacy duplicates +-- exist, because selecting an arbitrary row would erase audit history. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.dpco_client_policies + GROUP BY dpco_org_id, client_id, template_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'Cannot enforce dpco_client_policies natural key: duplicate (dpco_org_id, client_id, template_id) rows exist. Resolve and document duplicates before retrying migration.'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_dpco_client_policies_natural_key + ON public.dpco_client_policies (dpco_org_id, client_id, template_id); + +ALTER TYPE public.dpco_client_policy_status ADD VALUE IF NOT EXISTS 'customised'; +ALTER TYPE public.dpco_client_policy_status ADD VALUE IF NOT EXISTS 'delivered'; diff --git a/drizzle/0038_dpco_audit_control_rating_postgres_reconciliation.sql b/drizzle/0038_dpco_audit_control_rating_postgres_reconciliation.sql new file mode 100644 index 00000000..89ca5e5c --- /dev/null +++ b/drizzle/0038_dpco_audit_control_rating_postgres_reconciliation.sql @@ -0,0 +1,27 @@ +-- DPCO audit-control-rating PostgreSQL reconciliation. +-- DDL only. Existing duplicate natural keys block migration for accountable +-- remediation rather than silently discarding regulatory assessment history. + +ALTER TABLE public.dpco_audit_control_ratings + ADD COLUMN IF NOT EXISTS dpco_org_id integer, + ADD COLUMN IF NOT EXISTS control_ref varchar(255), + ADD COLUMN IF NOT EXISTS control_title varchar(255); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.dpco_audit_control_ratings + GROUP BY engagement_id, control_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'Cannot enforce dpco_audit_control_ratings natural key: duplicate (engagement_id, control_id) rows exist. Resolve and document duplicates before retrying migration.'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_dpco_audit_control_ratings_natural_key + ON public.dpco_audit_control_ratings (engagement_id, control_id); + +CREATE INDEX IF NOT EXISTS idx_dpco_audit_control_ratings_dpco_org + ON public.dpco_audit_control_ratings (dpco_org_id, engagement_id); diff --git a/drizzle/0039_lakehouse_durable_postgres_storage.sql b/drizzle/0039_lakehouse_durable_postgres_storage.sql new file mode 100644 index 00000000..d518f831 --- /dev/null +++ b/drizzle/0039_lakehouse_durable_postgres_storage.sql @@ -0,0 +1,72 @@ +-- Durable PostgreSQL source of truth for Lakehouse ingestion and feature-store metadata. +-- This migration contains DDL only. It intentionally creates no business, fixture, +-- release, or compliance evidence records. + +CREATE TABLE IF NOT EXISTS lakehouse_ingest_records ( + id UUID PRIMARY KEY, + table_name TEXT NOT NULL, + partition_key TEXT NOT NULL, + data JSONB NOT NULL, + schema_version TEXT NOT NULL, + source_system TEXT NOT NULL, + record_hash CHAR(64) NOT NULL UNIQUE, + delivery_status TEXT NOT NULL DEFAULT 'pending' + CHECK (delivery_status IN ('pending', 'retry', 'sending', 'delivered')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + leased_at TIMESTAMPTZ, + lease_expires_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK ((delivery_status <> 'sending') OR lease_expires_at IS NOT NULL) +); + +CREATE INDEX IF NOT EXISTS idx_lakehouse_ingest_pending_claim + ON lakehouse_ingest_records (next_attempt_at, created_at) + WHERE delivery_status IN ('pending', 'retry'); +CREATE INDEX IF NOT EXISTS idx_lakehouse_ingest_table_partition + ON lakehouse_ingest_records (table_name, partition_key, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_lakehouse_ingest_sending_lease + ON lakehouse_ingest_records (lease_expires_at) + WHERE delivery_status = 'sending'; + +CREATE TABLE IF NOT EXISTS ml_feature_store ( + id UUID PRIMARY KEY, + feature_group TEXT NOT NULL, + entity_id TEXT NOT NULL, + entity_type TEXT NOT NULL DEFAULT 'organization', + features JSONB NOT NULL DEFAULT '{}'::jsonb, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ml_feature_store_feature_group_entity_key UNIQUE (feature_group, entity_id) +); + +CREATE TABLE IF NOT EXISTS ml_prediction_log ( + id UUID PRIMARY KEY, + model_name TEXT NOT NULL, + model_version TEXT NOT NULL, + entity_id TEXT NOT NULL, + input_features JSONB NOT NULL DEFAULT '{}'::jsonb, + prediction JSONB NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 0, + latency_ms BIGINT NOT NULL DEFAULT 0 CHECK (latency_ms >= 0), + predicted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ml_lineage ( + id UUID PRIMARY KEY, + source_table TEXT NOT NULL, + target_table TEXT NOT NULL, + transformation TEXT NOT NULL, + record_count BIGINT NOT NULL DEFAULT 0 CHECK (record_count >= 0), + pipeline_run_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_feature_store_group_entity + ON ml_feature_store (feature_group, entity_id); +CREATE INDEX IF NOT EXISTS idx_prediction_log_model + ON ml_prediction_log (model_name, predicted_at DESC); +CREATE INDEX IF NOT EXISTS idx_lineage_pipeline + ON ml_lineage (pipeline_run_id, created_at DESC); diff --git a/drizzle/0040_payment_command_durable_lifecycle.sql b/drizzle/0040_payment_command_durable_lifecycle.sql new file mode 100644 index 00000000..bef0f716 --- /dev/null +++ b/drizzle/0040_payment_command_durable_lifecycle.sql @@ -0,0 +1,63 @@ +-- Durable NIP/RTGS payment command lifecycle. +-- TigerBeetle remains the double-entry authority; this table is the PostgreSQL +-- command/projection ledger that prevents loss of downstream work after restart. + +ALTER TYPE rtgs_status ADD VALUE IF NOT EXISTS 'pending_confirmation'; + +CREATE TYPE payment_command_kind AS ENUM ('nip', 'rtgs'); +CREATE TYPE payment_command_status AS ENUM ( + 'pending_ledger', 'processing_ledger', 'pending_settlement', 'processing_settlement', + 'pending_confirmation', 'completed', 'failed' +); + +CREATE TABLE payment_commands ( + id UUID PRIMARY KEY, + payment_kind payment_command_kind NOT NULL, + payment_reference VARCHAR(64) NOT NULL UNIQUE, + nip_transaction_id INTEGER REFERENCES nip_transactions(id) ON DELETE RESTRICT, + rtgs_transaction_id INTEGER REFERENCES rtgs_transactions(id) ON DELETE RESTRICT, + status payment_command_status NOT NULL DEFAULT 'pending_ledger', + amount BIGINT NOT NULL CHECK (amount > 0), + currency VARCHAR(3) NOT NULL CHECK (currency IN ('NGN', 'USD')), + debit_account VARCHAR(128) NOT NULL, + credit_account VARCHAR(128) NOT NULL, + tigerbeetle_transaction_id VARCHAR(128), + mojaloop_reference VARCHAR(128), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + lease_expires_at TIMESTAMPTZ, + last_error TEXT, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT payment_commands_target_shape CHECK ( + (payment_kind = 'nip' AND nip_transaction_id IS NOT NULL AND rtgs_transaction_id IS NULL) + OR (payment_kind = 'rtgs' AND rtgs_transaction_id IS NOT NULL AND nip_transaction_id IS NULL) + ), + CONSTRAINT payment_commands_external_ids CHECK ( + tigerbeetle_transaction_id IS NULL OR char_length(tigerbeetle_transaction_id) BETWEEN 1 AND 128 + ), + CONSTRAINT payment_commands_mojaloop_reference CHECK ( + mojaloop_reference IS NULL OR char_length(mojaloop_reference) BETWEEN 1 AND 128 + ) +); + +CREATE UNIQUE INDEX payment_commands_nip_transaction_unique + ON payment_commands (nip_transaction_id) + WHERE nip_transaction_id IS NOT NULL; + +CREATE UNIQUE INDEX payment_commands_rtgs_transaction_unique + ON payment_commands (rtgs_transaction_id) + WHERE rtgs_transaction_id IS NOT NULL; + +CREATE INDEX payment_commands_due_idx + ON payment_commands (status, next_attempt_at, created_at) + WHERE status IN ('pending_ledger', 'pending_settlement'); + +CREATE INDEX payment_commands_lease_idx + ON payment_commands (status, lease_expires_at) + WHERE status IN ('processing_ledger', 'processing_settlement'); + +CREATE INDEX payment_commands_mojaloop_reference_idx + ON payment_commands (mojaloop_reference) + WHERE mojaloop_reference IS NOT NULL; diff --git a/drizzle/0041_apisix_durable_route_registry.sql b/drizzle/0041_apisix_durable_route_registry.sql new file mode 100644 index 00000000..9ebf5939 --- /dev/null +++ b/drizzle/0041_apisix_durable_route_registry.sql @@ -0,0 +1,41 @@ +-- APISIX route configuration is an operational control plane, not application memory. +-- No route records are seeded here; route creation requires an authorized workflow. + +CREATE TYPE gateway_route_sync_status AS ENUM ('succeeded', 'failed'); + +CREATE TABLE gateway_routes ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(160) NOT NULL, + uri VARCHAR(512) NOT NULL UNIQUE, + methods TEXT[] NOT NULL, + upstream VARCHAR(512) NOT NULL, + plugins JSONB NOT NULL DEFAULT '{}'::jsonb, + journey_id VARCHAR(32), + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT gateway_routes_id_shape CHECK (id ~ '^[A-Za-z0-9._:-]+$' AND char_length(id) <= 64), + CONSTRAINT gateway_routes_uri_shape CHECK (uri ~ '^/[A-Za-z0-9._/*:.-]+$' AND char_length(uri) <= 512), + CONSTRAINT gateway_routes_methods_not_empty CHECK (cardinality(methods) > 0), + CONSTRAINT gateway_routes_upstream_shape CHECK (upstream ~ '^https?://[^[:space:]]+$' AND char_length(upstream) <= 512) +); + +CREATE TABLE gateway_route_sync_attempts ( + id UUID PRIMARY KEY, + route_id VARCHAR(64) NOT NULL REFERENCES gateway_routes(id) ON DELETE CASCADE, + route_version INTEGER NOT NULL, + status gateway_route_sync_status NOT NULL, + http_status INTEGER, + error_message TEXT, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT gateway_route_sync_attempts_outcome CHECK ( + (status = 'succeeded' AND http_status BETWEEN 200 AND 299 AND error_message IS NULL) + OR (status = 'failed' AND (http_status IS NULL OR http_status NOT BETWEEN 200 AND 299)) + ) +); + +CREATE INDEX gateway_routes_active_idx ON gateway_routes (is_active, id); +CREATE INDEX gateway_route_sync_attempts_route_time_idx ON gateway_route_sync_attempts (route_id, attempted_at DESC); diff --git a/drizzle/0042_theme_preferences_durable_storage.sql b/drizzle/0042_theme_preferences_durable_storage.sql new file mode 100644 index 00000000..038cf08d --- /dev/null +++ b/drizzle/0042_theme_preferences_durable_storage.sql @@ -0,0 +1,11 @@ +CREATE TABLE "theme_preferences" ( + "user_id" varchar(64) PRIMARY KEY NOT NULL, + "theme" varchar(16) NOT NULL DEFAULT 'light', + "last_seen_changelog_version" varchar(128), + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "theme_preferences_theme_check" CHECK ("theme" IN ('light', 'dark')), + CONSTRAINT "theme_preferences_user_id_nonempty_check" CHECK (char_length(trim("user_id")) > 0) +); + +CREATE INDEX "theme_preferences_updated_at_idx" ON "theme_preferences" USING btree ("updated_at"); diff --git a/drizzle/0043_audit_log_hash_chain_durable.sql b/drizzle/0043_audit_log_hash_chain_durable.sql new file mode 100644 index 00000000..cb55a7c6 --- /dev/null +++ b/drizzle/0043_audit_log_hash_chain_durable.sql @@ -0,0 +1,81 @@ +-- Durable, transactionally serialized hash-chain fields for newly written audit logs. +-- Historical records are deliberately not backfilled here: an unverifiable rewrite of +-- existing evidence would not establish non-repudiation. The verifier reports legacy +-- rows without a chain value as an integrity failure for any requested verification window. + +ALTER TABLE public.audit_logs + ADD COLUMN IF NOT EXISTS previous_hash TEXT, + ADD COLUMN IF NOT EXISTS hash_chain TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'audit_logs_previous_hash_sha256_shape' + AND conrelid = 'public.audit_logs'::regclass + ) THEN + ALTER TABLE public.audit_logs + ADD CONSTRAINT audit_logs_previous_hash_sha256_shape + CHECK (previous_hash IS NULL OR previous_hash ~ '^[0-9a-f]{64}$'); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'audit_logs_hash_chain_sha256_shape' + AND conrelid = 'public.audit_logs'::regclass + ) THEN + ALTER TABLE public.audit_logs + ADD CONSTRAINT audit_logs_hash_chain_sha256_shape + CHECK (hash_chain IS NULL OR hash_chain ~ '^[0-9a-f]{64}$'); + END IF; +END +$$; + +CREATE OR REPLACE FUNCTION public.assign_audit_log_hash_chain() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + prior_hash TEXT; + canonical_payload TEXT; +BEGIN + -- Serialize concurrent inserts so the ordered chain cannot fork under load. + PERFORM pg_advisory_xact_lock(8043, 1); + + IF NEW.previous_hash IS NOT NULL OR NEW.hash_chain IS NOT NULL THEN + RAISE EXCEPTION 'audit log chain values are database managed'; + END IF; + + SELECT hash_chain + INTO prior_hash + FROM public.audit_logs + WHERE hash_chain IS NOT NULL + ORDER BY id DESC + LIMIT 1; + + NEW.previous_hash := prior_hash; + canonical_payload := concat( + COALESCE(prior_hash, 'GENESIS'), '|', + NEW.action, '|', + COALESCE(NEW.resource_type, ''), '|', + COALESCE(NEW.resource_id::text, ''), '|', + COALESCE(NEW.user_id::text, ''), '|', + COALESCE(NEW.details, '{}'), '|', + NEW.created_at::text + ); + NEW.hash_chain := encode(digest(canonical_payload, 'sha256'), 'hex'); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS audit_logs_assign_hash_chain ON public.audit_logs; +CREATE TRIGGER audit_logs_assign_hash_chain +BEFORE INSERT ON public.audit_logs +FOR EACH ROW +EXECUTE FUNCTION public.assign_audit_log_hash_chain(); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_hash_chain + ON public.audit_logs (hash_chain) + WHERE hash_chain IS NOT NULL; diff --git a/drizzle/0044_runtime_schema_ownership_and_rls.sql b/drizzle/0044_runtime_schema_ownership_and_rls.sql new file mode 100644 index 00000000..87847a16 --- /dev/null +++ b/drizzle/0044_runtime_schema_ownership_and_rls.sql @@ -0,0 +1,57 @@ +-- Runtime schema ownership and RLS reconciliation. +-- This migration owns the feature-flag columns formerly added at application startup +-- and the DPCO organization-isolation policy state formerly mutated at startup. + +ALTER TABLE public.feature_flags + ADD COLUMN IF NOT EXISTS strategy varchar(32) NOT NULL DEFAULT 'off', + ADD COLUMN IF NOT EXISTS parameters jsonb NOT NULL DEFAULT '{}'::jsonb; + +DO $$ +DECLARE + target_table text; + organization_column text; + policy_name text; +BEGIN + FOR target_table, organization_column IN + SELECT * FROM (VALUES + ('dpco_clients', 'dpco_org_id'), + ('dpco_audit_engagements', 'dpco_org_id'), + ('dpco_training_sessions', 'dpco_org_id'), + ('dpco_policy_drafts', 'dpco_org_id'), + ('dpco_subscriptions', 'dpco_org_id'), + ('dpco_invoices', 'dpco_org_id'), + ('dpco_payments', 'dpco_org_id'), + ('platform_revenue_splits', 'dpco_org_id'), + ('dpco_organisations', 'id') + ) AS scoped_tables(table_name, organization_column_name) + LOOP + IF to_regclass(format('public.%I', target_table)) IS NULL THEN + RAISE EXCEPTION 'required RLS table public.% is missing; apply canonical migrations before 0044', target_table; + END IF; + + EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', target_table); + policy_name := target_table || '_org_isolation'; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = target_table + AND policyname = policy_name + ) THEN + EXECUTE format( + 'CREATE POLICY %I ON public.%I FOR ALL USING ( + %I = NULLIF(current_setting(''app.current_org_id'', true), '''')::integer + OR current_setting(''app.is_admin'', true) = ''true'' + ) WITH CHECK ( + %I = NULLIF(current_setting(''app.current_org_id'', true), '''')::integer + OR current_setting(''app.is_admin'', true) = ''true'' + )', + policy_name, + target_table, + organization_column, + organization_column + ); + END IF; + END LOOP; +END +$$; diff --git a/drizzle/0045_webhook_delivery_attempts_shadow_queue.sql b/drizzle/0045_webhook_delivery_attempts_shadow_queue.sql new file mode 100644 index 00000000..43f28d3b --- /dev/null +++ b/drizzle/0045_webhook_delivery_attempts_shadow_queue.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS webhook_delivery_attempts ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + subscription_id integer NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE RESTRICT, + event_id uuid NOT NULL, + event_type varchar(128) NOT NULL, + payload jsonb NOT NULL, + destination_url text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'shadow', + attempt_count integer NOT NULL DEFAULT 0, + max_attempts integer NOT NULL DEFAULT 3, + next_retry_at timestamptz NOT NULL DEFAULT now(), + claimed_at timestamptz, + delivered_at timestamptz, + last_response_code integer, + idempotency_key varchar(160) NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT webhook_delivery_attempt_status_check + CHECK (status IN ('shadow', 'pending', 'processing', 'retrying', 'delivered', 'dead')), + CONSTRAINT webhook_delivery_attempt_count_check CHECK (attempt_count >= 0), + CONSTRAINT webhook_delivery_attempt_max_attempts_check CHECK (max_attempts > 0), + CONSTRAINT webhook_delivery_attempt_processing_claim_check + CHECK ((status = 'processing' AND claimed_at IS NOT NULL) OR status <> 'processing') +); + +DO $$ +BEGIN + ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_status_check + CHECK (status IN ('shadow', 'pending', 'processing', 'retrying', 'delivered', 'dead')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_count_check CHECK (attempt_count >= 0); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_max_attempts_check CHECK (max_attempts > 0); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_processing_claim_check + CHECK ((status = 'processing' AND claimed_at IS NOT NULL) OR status <> 'processing'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_attempts_claim + ON webhook_delivery_attempts (next_retry_at, id) + WHERE status IN ('pending', 'retrying'); + +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_attempts_event + ON webhook_delivery_attempts (subscription_id, event_id); + +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_attempts_shadow + ON webhook_delivery_attempts (created_at, id) + WHERE status = 'shadow'; diff --git a/drizzle/0046_webhook_delivery_attempt_claim_leases.sql b/drizzle/0046_webhook_delivery_attempt_claim_leases.sql new file mode 100644 index 00000000..acaa5563 --- /dev/null +++ b/drizzle/0046_webhook_delivery_attempt_claim_leases.sql @@ -0,0 +1,75 @@ +-- Migration-owned claim/lease fields for the additive webhook retry queue. +-- The canonical webhook_deliveries ledger remains unchanged. + +ALTER TABLE webhook_delivery_attempts + ADD COLUMN IF NOT EXISTS claim_token uuid, + ADD COLUMN IF NOT EXISTS claim_owner varchar(128), + ADD COLUMN IF NOT EXISTS claim_expires_at timestamptz, + ADD COLUMN IF NOT EXISTS last_error text; + +ALTER TABLE webhook_delivery_attempts + DROP CONSTRAINT IF EXISTS webhook_delivery_attempt_processing_claim_check; + +ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_processing_claim_check + CHECK ( + ( + status = 'processing' + AND claimed_at IS NOT NULL + AND claim_token IS NOT NULL + AND claim_owner IS NOT NULL + AND claim_expires_at IS NOT NULL + ) + OR status <> 'processing' + ); + +ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_terminal_claim_clear_check + CHECK ( + status NOT IN ('delivered', 'dead') + OR ( + claim_token IS NULL + AND claim_owner IS NULL + AND claim_expires_at IS NULL + ) + ); + +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_attempts_claimable + ON webhook_delivery_attempts (next_retry_at, id) + WHERE status IN ('pending', 'retrying'); + +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_attempts_expired_lease + ON webhook_delivery_attempts (claim_expires_at, id) + WHERE status = 'processing'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_delivery_attempts_claim_token + ON webhook_delivery_attempts (claim_token) + WHERE claim_token IS NOT NULL; + +-- Exactly one canonical ledger outcome may be recorded for each queue attempt. +-- The column can already exist when the root schema is materialized first, so +-- enforce the migration-owned foreign key separately rather than relying on an +-- ignored inline REFERENCES clause. Like the preceding ALTER TABLE checks, this +-- takes a short DDL lock and must be scheduled through the reviewed migration path. +ALTER TABLE webhook_deliveries + ADD COLUMN IF NOT EXISTS queue_attempt_id bigint; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'webhook_deliveries'::regclass + AND conname = 'webhook_deliveries_queue_attempt_id_fk' + ) THEN + ALTER TABLE webhook_deliveries + ADD CONSTRAINT webhook_deliveries_queue_attempt_id_fk + FOREIGN KEY (queue_attempt_id) + REFERENCES webhook_delivery_attempts(id) + ON DELETE RESTRICT; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_deliveries_queue_attempt + ON webhook_deliveries (queue_attempt_id) + WHERE queue_attempt_id IS NOT NULL; diff --git a/drizzle/0047_webhook_delivery_attempt_lease_window.sql b/drizzle/0047_webhook_delivery_attempt_lease_window.sql new file mode 100644 index 00000000..381e8440 --- /dev/null +++ b/drizzle/0047_webhook_delivery_attempt_lease_window.sql @@ -0,0 +1,12 @@ +-- A processing queue record must carry a future lease relative to its claim time. +-- NOT VALID enforces the invariant for new writes immediately; validation confirms +-- the existing migration-owned queue is consistent without mutating application data. +ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempt_processing_lease_window_check + CHECK ( + status <> 'processing' + OR claim_expires_at > claimed_at + ) NOT VALID; + +ALTER TABLE webhook_delivery_attempts + VALIDATE CONSTRAINT webhook_delivery_attempt_processing_lease_window_check; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index dda698c0..db509d7a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -245,6 +245,97 @@ "when": 1786640400000, "tag": "0034_dpco_final_lifecycle_identifier_checks", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1788219175186, + "tag": "0035_digital_twin_noc_runtime_schema", + "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1788277860687, + "tag": "0036_dpco_analytics_durable_events", + "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1788278000000, + "tag": "0037_dpco_client_policy_postgres_reconciliation", + "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1788278100000, + "tag": "0038_dpco_audit_control_rating_postgres_reconciliation", + "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1788282893000, + "tag": "0039_lakehouse_durable_postgres_storage", + "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1788289577146, + "tag": "0040_payment_command_durable_lifecycle", + "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1788291472264, + "tag": "0041_apisix_durable_route_registry", + "breakpoints": true + }, + { + "idx": 42, + "version": "7", + "when": 1788315554964, + "tag": "0042_theme_preferences_durable_storage", + "breakpoints": true + }, + { + "idx": 43, + "version": "7", + "when": 1788348294858, + "tag": "0043_audit_log_hash_chain_durable", + "breakpoints": true + }, + { + "idx": 44, + "version": "7", + "when": 1788389839397, + "tag": "0044_runtime_schema_ownership_and_rls", + "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1788422400000, + "tag": "0045_webhook_delivery_attempts_shadow_queue", + "breakpoints": true + }, + { + "idx": 46, + "version": "7", + "when": 1788433770654, + "tag": "0046_webhook_delivery_attempt_claim_leases", + "breakpoints": true + }, + { + "idx": 47, + "version": "7", + "when": 1788441709235, + "tag": "0047_webhook_delivery_attempt_lease_window", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/schema.ts b/drizzle/schema.ts index f9795afa..4ee4c4c6 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { bigint, boolean, @@ -12,6 +13,7 @@ import { serial, text, timestamp, + uuid, varchar, } from "drizzle-orm/pg-core"; @@ -46,6 +48,19 @@ export const users = pgTable("users", { export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; +// ─── Per-user UI preferences ───────────────────────────────────────────────── + +export const themePreferences = pgTable("theme_preferences", { + userId: varchar("user_id", { length: 64 }).primaryKey(), + theme: varchar("theme", { length: 16 }).notNull().default("light"), + lastSeenChangelogVersion: varchar("last_seen_changelog_version", { length: 128 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export type ThemePreference = typeof themePreferences.$inferSelect; +export type InsertThemePreference = typeof themePreferences.$inferInsert; + // ─── Organizations ──────────────────────────────────────────────────────────── export const organizations = pgTable("organizations", { @@ -73,6 +88,118 @@ export const organizations = pgTable("organizations", { export type Organization = typeof organizations.$inferSelect; export type InsertOrganization = typeof organizations.$inferInsert; +// ─── Webhook subscriptions and durable delivery ledger ─────────────────────── + +export const webhookSubscriptions = pgTable("webhook_subscriptions", { + id: serial("id").primaryKey(), + orgId: integer("org_id").notNull(), + url: text("url").notNull(), + events: text("events").array().notNull().default(sql`'{}'`), + secret: text("secret").notNull(), + active: boolean("active").default(true), + failureCount: integer("failure_count").default(0), + lastDeliveryAt: timestamp("last_delivery_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); + +export const webhookDeliveries = pgTable("webhook_deliveries", { + id: serial("id").primaryKey(), + subscriptionId: integer("subscription_id").references(() => webhookSubscriptions.id), + event: text("event").notNull(), + payload: jsonb("payload").notNull(), + responseStatus: integer("response_status"), + responseBody: text("response_body"), + attempt: integer("attempt").default(1), + deliveredAt: timestamp("delivered_at", { withTimezone: true }).defaultNow(), + success: boolean("success").default(false), + // Foreign-key enforcement is owned by migration 0046; the queue table is declared below. + queueAttemptId: bigint("queue_attempt_id", { mode: "number" }), +}); + +export const webhookDeliveryAttempts = pgTable("webhook_delivery_attempts", { + id: bigint("id", { mode: "number" }).primaryKey().generatedByDefaultAsIdentity(), + subscriptionId: integer("subscription_id").notNull().references(() => webhookSubscriptions.id), + eventId: uuid("event_id").notNull(), + eventType: varchar("event_type", { length: 128 }).notNull(), + payload: jsonb("payload").notNull(), + destinationUrl: text("destination_url").notNull(), + status: varchar("status", { length: 16 }).notNull().default("shadow"), + attemptCount: integer("attempt_count").notNull().default(0), + maxAttempts: integer("max_attempts").notNull().default(3), + nextRetryAt: timestamp("next_retry_at", { withTimezone: true }).notNull().defaultNow(), + claimedAt: timestamp("claimed_at", { withTimezone: true }), + claimToken: uuid("claim_token"), + claimOwner: varchar("claim_owner", { length: 128 }), + claimExpiresAt: timestamp("claim_expires_at", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + lastResponseCode: integer("last_response_code"), + lastError: text("last_error"), + idempotencyKey: varchar("idempotency_key", { length: 160 }).notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export type WebhookSubscriptionRecord = typeof webhookSubscriptions.$inferSelect; +export type WebhookDeliveryRecord = typeof webhookDeliveries.$inferSelect; +export type WebhookDeliveryAttemptRecord = typeof webhookDeliveryAttempts.$inferSelect; + +// ─── Durable Lakehouse ingestion and feature store ─────────────────────────── + +export const lakehouseIngestRecords = pgTable("lakehouse_ingest_records", { + id: uuid("id").primaryKey(), + tableName: text("table_name").notNull(), + partitionKey: text("partition_key").notNull(), + data: jsonb("data").notNull(), + schemaVersion: text("schema_version").notNull(), + sourceSystem: text("source_system").notNull(), + recordHash: varchar("record_hash", { length: 64 }).notNull().unique(), + deliveryStatus: varchar("delivery_status", { length: 16 }).notNull().default("pending"), + attempts: integer("attempts").notNull().default(0), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(), + leasedAt: timestamp("leased_at", { withTimezone: true }), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const mlFeatureStore = pgTable("ml_feature_store", { + id: uuid("id").primaryKey(), + featureGroup: text("feature_group").notNull(), + entityId: text("entity_id").notNull(), + entityType: text("entity_type").notNull().default("organization"), + features: jsonb("features").notNull().default(sql`'{}'::jsonb`), + recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const mlPredictionLog = pgTable("ml_prediction_log", { + id: uuid("id").primaryKey(), + modelName: text("model_name").notNull(), + modelVersion: text("model_version").notNull(), + entityId: text("entity_id").notNull(), + inputFeatures: jsonb("input_features").notNull().default(sql`'{}'::jsonb`), + prediction: jsonb("prediction").notNull(), + confidence: real("confidence").notNull().default(0), + latencyMs: bigint("latency_ms", { mode: "number" }).notNull().default(0), + predictedAt: timestamp("predicted_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const mlLineage = pgTable("ml_lineage", { + id: uuid("id").primaryKey(), + sourceTable: text("source_table").notNull(), + targetTable: text("target_table").notNull(), + transformation: text("transformation").notNull(), + recordCount: bigint("record_count", { mode: "number" }).notNull().default(0), + pipelineRunId: text("pipeline_run_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export type LakehouseIngestRecord = typeof lakehouseIngestRecords.$inferSelect; +export type MlFeatureStoreRecord = typeof mlFeatureStore.$inferSelect; +export type MlPredictionLogRecord = typeof mlPredictionLog.$inferSelect; +export type MlLineageRecord = typeof mlLineage.$inferSelect; + // ─── Assets ─────────────────────────────────────────────────────────────────── export const assets = pgTable("assets", { @@ -293,13 +420,15 @@ export const auditLogs = pgTable("audit_logs", { resourceId: integer("resource_id"), details: text("details"), ipAddress: varchar("ip_address", { length: 64 }), - userAgent: text("user_agent"), + userAgent: text("user_agent"), metadata: jsonb("metadata"), + previousHash: text("previous_hash"), + hashChain: text("hash_chain"), createdAt: timestamp("created_at").defaultNow().notNull(), }); - export type AuditLog = typeof auditLogs.$inferSelect; + // ─── Streaming Events ───────────────────────────────────────────────────────── export const streamingEvents = pgTable("streaming_events", { @@ -1512,7 +1641,10 @@ export const controlRatingEnum = pgEnum("control_rating", ["compliant", "partial export const dpcoAuditControlRatings = pgTable("dpco_audit_control_ratings", { id: serial("id").primaryKey(), engagementId: integer("engagement_id").notNull(), + dpcoOrgId: integer("dpco_org_id"), controlId: varchar("control_id", { length: 20 }).notNull(), + controlRef: varchar("control_ref", { length: 255 }), + controlTitle: varchar("control_title", { length: 255 }), rating: controlRatingEnum("rating").notNull(), notes: text("notes"), ratedBy: varchar("rated_by", { length: 255 }), @@ -1574,7 +1706,7 @@ export const dpcoTrainingSessions = pgTable("dpco_training_sessions", { export type DpcoTrainingSession = typeof dpcoTrainingSessions.$inferSelect; // ── DPCO Client Policies ────────────────────────────────────────────────────── -export const dpcoClientPolicyStatusEnum = pgEnum("dpco_client_policy_status", ["draft", "reviewed", "signed", "expired"]); +export const dpcoClientPolicyStatusEnum = pgEnum("dpco_client_policy_status", ["draft", "customised", "reviewed", "signed", "delivered", "expired"]); export const dpcoClientPolicies = pgTable("dpco_client_policies", { id: serial("id").primaryKey(), @@ -1924,7 +2056,7 @@ export type NipTransaction = typeof nipTransactions.$inferSelect; export type InsertNipTransaction = typeof nipTransactions.$inferInsert; export const rtgsStatusEnum = pgEnum("rtgs_status", [ - "queued", "processing", "settled", "rejected", "cancelled", "pending_funds" + "queued", "processing", "settled", "rejected", "cancelled", "pending_funds", "pending_confirmation" ]); export const rtgsTransactions = pgTable("rtgs_transactions", { @@ -1951,6 +2083,36 @@ export const rtgsTransactions = pgTable("rtgs_transactions", { export type RtgsTransaction = typeof rtgsTransactions.$inferSelect; export type InsertRtgsTransaction = typeof rtgsTransactions.$inferInsert; +export const paymentCommandKindEnum = pgEnum("payment_command_kind", ["nip", "rtgs"]); +export const paymentCommandStatusEnum = pgEnum("payment_command_status", [ + "pending_ledger", "processing_ledger", "pending_settlement", "processing_settlement", + "pending_confirmation", "completed", "failed", +]); + +export const paymentCommands = pgTable("payment_commands", { + id: uuid("id").primaryKey().notNull(), + paymentKind: paymentCommandKindEnum("payment_kind").notNull(), + paymentReference: varchar("payment_reference", { length: 64 }).unique().notNull(), + nipTransactionId: integer("nip_transaction_id").references(() => nipTransactions.id, { onDelete: "restrict" }), + rtgsTransactionId: integer("rtgs_transaction_id").references(() => rtgsTransactions.id, { onDelete: "restrict" }), + status: paymentCommandStatusEnum("status").default("pending_ledger").notNull(), + amount: bigint("amount", { mode: "number" }).notNull(), + currency: varchar("currency", { length: 3 }).notNull(), + debitAccount: varchar("debit_account", { length: 128 }).notNull(), + creditAccount: varchar("credit_account", { length: 128 }).notNull(), + tigerbeetleTransactionId: varchar("tigerbeetle_transaction_id", { length: 128 }), + mojaloopReference: varchar("mojaloop_reference", { length: 128 }), + attempts: integer("attempts").default(0).notNull(), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).defaultNow().notNull(), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + lastError: text("last_error"), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); +export type PaymentCommand = typeof paymentCommands.$inferSelect; +export type InsertPaymentCommand = typeof paymentCommands.$inferInsert; + export const swiftStatusEnum = pgEnum("swift_status", [ "draft", "sent", "acknowledged", "processed", "rejected", "recalled" ]); @@ -3079,3 +3241,33 @@ export const penaltyCalculations = pgTable("penalty_calculations", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); + + +// ─── APISIX Durable Gateway Route Registry ─────────────────────────────────── +export const gatewayRouteSyncStatusEnum = pgEnum("gateway_route_sync_status", ["succeeded", "failed"]); + +export const gatewayRoutes = pgTable("gateway_routes", { + id: varchar("id", { length: 64 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + uri: varchar("uri", { length: 512 }).notNull().unique(), + methods: text("methods").array().notNull(), + upstream: varchar("upstream", { length: 512 }).notNull(), + plugins: jsonb("plugins").$type>().notNull().default({}), + journeyId: varchar("journey_id", { length: 32 }), + description: text("description"), + isActive: boolean("is_active").notNull().default(true), + version: integer("version").notNull().default(1), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const gatewayRouteSyncAttempts = pgTable("gateway_route_sync_attempts", { + id: uuid("id").primaryKey().defaultRandom(), + routeId: varchar("route_id", { length: 64 }).notNull().references(() => gatewayRoutes.id, { onDelete: "cascade" }), + routeVersion: integer("route_version").notNull(), + status: gatewayRouteSyncStatusEnum("status").notNull(), + httpStatus: integer("http_status"), + errorMessage: text("error_message"), + attemptedAt: timestamp("attempted_at", { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp("completed_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/e2e/dpco-onboarding.spec.ts b/e2e/dpco-onboarding.spec.ts index 97655a3e..192c2281 100644 --- a/e2e/dpco-onboarding.spec.ts +++ b/e2e/dpco-onboarding.spec.ts @@ -105,20 +105,20 @@ test.describe("DPCO Onboarding 2: Certificate verification", () => { expect(result.data.valid).toBe(false); }); - test("verify.certificate returns valid=true for a known token (if available)", async ({ page }) => { + test("verify.certificate returns a valid public verification contract with or without seeded certificates", async ({ page }) => { await page.goto(BASE); const listResult = await trpcGet(page, "accreditation.publicListDpcos"); + expect(listResult.status).toBe(200); + expect(Array.isArray(listResult.data)).toBe(true); + const dpcos = listResult.data as Array<{ certificate_token?: string }>; - const withToken = dpcos?.find((d) => d.certificate_token); - if (withToken?.certificate_token) { - const result = await trpcGet(page, "verify.certificate", { - token: withToken.certificate_token, - }); - expect(result.status).toBe(200); - expect(typeof result.data.valid).toBe("boolean"); - } else { - test.skip(true, "No certified DPCO with certificate_token in demo data"); - } + const token = dpcos.find((d) => d.certificate_token)?.certificate_token + ?? "NDPC-DPCO-NONEXISTENT-TOKEN"; + const result = await trpcGet(page, "verify.certificate", { token }); + + expect(result.status).toBe(200); + expect(typeof result.data?.valid).toBe("boolean"); + if (token === "NDPC-DPCO-NONEXISTENT-TOKEN") expect(result.data.valid).toBe(false); }); }); diff --git a/e2e/enforcement-loop.spec.ts b/e2e/enforcement-loop.spec.ts index 35620ae0..790bad90 100644 --- a/e2e/enforcement-loop.spec.ts +++ b/e2e/enforcement-loop.spec.ts @@ -22,14 +22,15 @@ async function trpcQuery( path: string, input?: unknown ): Promise { - const url = input !== undefined - ? `${BASE}/api/trpc/${path}?input=${encodeURIComponent(JSON.stringify(input))}` + const wrapped = input !== undefined ? { json: input } : undefined; + const url = wrapped + ? `${BASE}/api/trpc/${path}?input=${encodeURIComponent(JSON.stringify(wrapped))}` : `${BASE}/api/trpc/${path}`; const res = await page.request.get(url, { headers: { "Content-Type": "application/json" }, }); const json = await res.json(); - return json?.result?.data ?? json; + return json?.result?.data?.json ?? json?.result?.data ?? json; } async function trpcMutate( @@ -45,6 +46,11 @@ async function trpcMutate( return json?.["0"]?.result?.data ?? json; } +/** Test-environment session setup only; authenticated Keycloak acceptance is staged separately. */ +async function demoLogin(page: Page, role: "admin" | "dpco") { + await page.goto(`${BASE}/api/demo-login?role=${role}`, { waitUntil: "domcontentloaded" }); +} + // ─── Flow 1: Organization → Violation → Penalty ─────────────────────────────── test.describe("Flow 1: Organization Create → Violation Detect → Penalty Issue", () => { @@ -185,22 +191,14 @@ test.describe("Flow 3: Evidence Package Generate → Verify → Export JSON", () } }); - test("tRPC evidencePackages.verify query works", async ({ page }) => { - await page.goto(BASE); - // First get a package to verify - const packages = await trpcQuery(page, "evidencePackages.list") as any[]; - if (Array.isArray(packages) && packages.length > 0) { - const pkg = packages[0]; - const result = await trpcQuery(page, "evidencePackages.verify", { - packageId: pkg.id, - contentHash: pkg.contentHash, - signature: pkg.signature, - }); - expect(result).toBeDefined(); - } else { - // No packages yet — just verify the endpoint exists - test.skip(true, "No evidence packages in DB yet — run pnpm db:seed first"); - } + test("tRPC evidencePackages.verify rejects an unknown evidence hash with the real verifier contract", async ({ page }) => { + await demoLogin(page, "admin"); + const result = await trpcQuery(page, "evidencePackages.verify", { + contentHash: "sha256-e2e-unknown-evidence", + hmacSignature: "hmac-e2e-unknown-evidence", + }); + + expect(result).toEqual({ valid: false, package: null }); }); }); diff --git a/e2e/tests/critical-workflows.spec.ts b/e2e/tests/critical-workflows.spec.ts index 90357380..75362110 100644 --- a/e2e/tests/critical-workflows.spec.ts +++ b/e2e/tests/critical-workflows.spec.ts @@ -1,105 +1,76 @@ -/** +/* * NDSEP Critical Workflow E2E Tests * - * Tests the golden-path user flows that must work for production readiness. + * This CI shard runs without Keycloak credentials. It verifies that the public + * application shell is responsive, unauthenticated users cannot see privileged + * navigation, protected routes present an explicit sign-in boundary, and public + * API endpoints use valid paths. Authenticated workflow acceptance belongs in + * the protected staging suite, with a real Keycloak-issued session. */ import { test, expect } from "@playwright/test"; -test.describe("Dashboard", () => { - test("loads main dashboard with compliance overview", async ({ page }) => { - await page.goto("/"); - await expect(page.locator("body")).toBeVisible(); - // Performance budget: page should load within 3s - const timing = await page.evaluate(() => performance.timing.loadEventEnd - performance.timing.navigationStart); - expect(timing).toBeLessThan(5000); - }); - - test("sidebar navigation has all critical sections", async ({ page }) => { - await page.goto("/"); - const sidebar = page.locator("nav, [role=navigation]").first(); - await expect(sidebar).toBeVisible({ timeout: 10000 }); - }); -}); - -test.describe("Compliance Workflows", () => { - test("organization list loads with data", async ({ page }) => { - await page.goto("/organizations"); - await page.waitForLoadState("domcontentloaded"); - // Should have either org cards or a table - const content = await page.textContent("body"); - expect(content).toBeTruthy(); - }); +const SIGN_IN_HEADING = "Sign in to continue"; - test("enforcement dashboard loads", async ({ page }) => { - await page.goto("/enforcement"); - await page.waitForLoadState("domcontentloaded"); - const content = await page.textContent("body"); - expect(content).toBeTruthy(); - }); +async function expectUnauthenticatedBoundary(page: import("@playwright/test").Page) { + await expect(page.getByRole("heading", { name: SIGN_IN_HEADING })).toBeVisible(); + await expect(page.locator("nav, [role=navigation]")).toHaveCount(0); +} - test("breach incidents page loads", async ({ page }) => { - await page.goto("/breach-incidents"); - await page.waitForLoadState("domcontentloaded"); - const content = await page.textContent("body"); - expect(content).toBeTruthy(); +test.describe("Unauthenticated dashboard boundary", () => { + test("loads the secure sign-in state without evaluating privileged navigation", async ({ page }) => { + const startedAt = Date.now(); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expectUnauthenticatedBoundary(page); + expect(Date.now() - startedAt).toBeLessThan(5000); }); - test("data transfers page loads", async ({ page }) => { - await page.goto("/data-transfers"); - await page.waitForLoadState("domcontentloaded"); - const content = await page.textContent("body"); - expect(content).toBeTruthy(); - }); -}); - -test.describe("Network Intelligence", () => { - test("network intelligence page loads with tabs", async ({ page }) => { - await page.goto("/network-intelligence"); - await page.waitForLoadState("domcontentloaded"); - const content = await page.textContent("body"); - expect(content).toBeTruthy(); + test("does not render sidebar navigation before authentication", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expectUnauthenticatedBoundary(page); }); }); -test.describe("Sector Dashboards", () => { - const sectors = [ - { path: "/banking", name: "Banking" }, - { path: "/telecom", name: "Telecom" }, - { path: "/healthcare", name: "Healthcare" }, - { path: "/insurance", name: "Insurance" }, - { path: "/energy", name: "Energy" }, +test.describe("Protected route boundaries", () => { + const protectedRoutes = [ + "/organizations", + "/enforcement", + "/breach-incidents", + "/data-transfers", + "/network-intelligence", + "/banking", + "/telecom", + "/healthcare", + "/insurance", + "/energy", ]; - for (const sector of sectors) { - test(`${sector.name} dashboard loads`, async ({ page }) => { - await page.goto(sector.path); - await page.waitForLoadState("domcontentloaded"); - const content = await page.textContent("body"); - expect(content).toBeTruthy(); + for (const path of protectedRoutes) { + test(`${path} presents the sign-in boundary without privileged content`, async ({ page }) => { + await page.goto(path, { waitUntil: "domcontentloaded" }); + await expectUnauthenticatedBoundary(page); }); } }); -test.describe("API Responses", () => { - test("health endpoint returns 200", async ({ request }) => { - const response = await request.get("/health"); - expect(response.status()).toBeLessThan(500); +test.describe("Public API responses", () => { + test("health endpoint returns a successful health response", async ({ request }) => { + const response = await request.get("/api/health"); + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toEqual(expect.objectContaining({ status: expect.any(String) })); }); - test("tRPC endpoint exists", async ({ request }) => { - const response = await request.get("/api/trpc"); - // Should not be 404 — could be 401 (auth required) or 400 (bad request) - expect(response.status()).not.toBe(404); + test("auth.me tRPC procedure returns a valid unauthenticated response", async ({ request }) => { + const response = await request.get("/api/trpc/auth.me?batch=1&input=%7B%7D"); + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toEqual(expect.any(Object)); }); }); -test.describe("Performance Budgets", () => { - test("main page loads under performance budget", async ({ page }) => { - const start = Date.now(); - await page.goto("/"); - await page.waitForLoadState("domcontentloaded"); - const elapsed = Date.now() - start; - // Page should be interactive within 5s - expect(elapsed).toBeLessThan(5000); +test.describe("Performance budgets", () => { + test("the unauthenticated main page reaches the real sign-in boundary within budget", async ({ page }) => { + const startedAt = Date.now(); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expectUnauthenticatedBoundary(page); + expect(Date.now() - startedAt).toBeLessThan(5000); }); }); diff --git a/e2e/visual-baseline-contract.json b/e2e/visual-baseline-contract.json new file mode 100644 index 00000000..f441e2ac --- /dev/null +++ b/e2e/visual-baseline-contract.json @@ -0,0 +1,106 @@ +{ + "schemaVersion": 1, + "project": "chromium", + "matrix": { + "workers": 1, + "retries": 0 + }, + "snapshots": [ + { + "id": "login-page", + "path": "/", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "h1, .hero, [data-testid='login']", + "snapshot": "login-page.png", + "fullPage": true + }, + { + "id": "dashboard-home", + "path": "/dashboard", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": ".dashboard-stats, [data-testid='stats'], h1", + "snapshot": "dashboard-home.png", + "fullPage": true + }, + { + "id": "penalty-table", + "path": "/financial-enforcement/penalties", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "table, [data-testid='penalty-table'], h1", + "snapshot": "penalty-table.png", + "fullPage": true + }, + { + "id": "compliance-calendar", + "path": "/compliance/calendar", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": ".calendar, [data-testid='calendar'], h1", + "snapshot": "compliance-calendar.png", + "fullPage": true + }, + { + "id": "aml-cases", + "path": "/banking/aml", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "table, [data-testid='aml-table'], h1", + "snapshot": "aml-cases.png", + "fullPage": true + }, + { + "id": "kyc-records", + "path": "/banking/kyc", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "table, [data-testid='kyc-table'], h1", + "snapshot": "kyc-records.png", + "fullPage": true + }, + { + "id": "organizations-list", + "path": "/organizations", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "table, [data-testid='org-table'], h1", + "snapshot": "organizations-list.png", + "fullPage": true + }, + { + "id": "audit-logs", + "path": "/audit-logs", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "table, [data-testid='audit-table'], h1", + "snapshot": "audit-logs.png", + "fullPage": true + }, + { + "id": "siem-dashboard", + "path": "/siem", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "[data-testid='siem'], h1, .siem", + "snapshot": "siem-dashboard.png", + "fullPage": true + }, + { + "id": "breach-notifications", + "path": "/breach-notifications", + "viewport": { "width": 1440, "height": 900 }, + "waitFor": "table, [data-testid='breach-table'], h1", + "snapshot": "breach-notifications.png", + "fullPage": true + }, + { + "id": "mobile-viewport-dashboard", + "path": "/dashboard", + "viewport": { "width": 390, "height": 844 }, + "waitFor": null, + "snapshot": "dashboard-mobile.png", + "fullPage": false + }, + { + "id": "tablet-viewport-compliance-calendar", + "path": "/compliance/calendar", + "viewport": { "width": 768, "height": 1024 }, + "waitFor": null, + "snapshot": "compliance-calendar-tablet.png", + "fullPage": false + } + ] +} diff --git a/e2e/visual-baseline-review.json b/e2e/visual-baseline-review.json new file mode 100644 index 00000000..ee4ff77b --- /dev/null +++ b/e2e/visual-baseline-review.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 2, + "status": "PENDING_INDEPENDENT_HUMAN_REVIEW", + "captureCommit": "", + "pullRequest": { + "number": 18, + "captureCommit": "" + }, + "matrix": { + "project": "chromium", + "workers": 1, + "retries": 0 + }, + "snapshots": [ + { + "id": "login-page", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "dashboard-home", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "penalty-table", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "compliance-calendar", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { "id": "aml-cases", "sha256": "", "decision": "pending", "rationale": "" }, + { + "id": "kyc-records", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "organizations-list", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "audit-logs", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "siem-dashboard", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "breach-notifications", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "mobile-viewport-dashboard", + "sha256": "", + "decision": "pending", + "rationale": "" + }, + { + "id": "tablet-viewport-compliance-calendar", + "sha256": "", + "decision": "pending", + "rationale": "" + } + ], + "approvals": [ + { + "role": "product-ux-owner", + "actor": "", + "decision": "pending", + "reviewedAt": "", + "evidenceUri": "" + }, + { + "role": "qa-lead", + "actor": "", + "decision": "pending", + "reviewedAt": "", + "evidenceUri": "" + }, + { + "role": "accessibility-owner", + "actor": "", + "decision": "pending", + "reviewedAt": "", + "evidenceUri": "" + }, + { + "role": "independent-engineering-reviewer", + "actor": "", + "decision": "pending", + "reviewedAt": "", + "evidenceUri": "" + } + ], + "prohibition": "This record is a pending review template. It does not approve visual baselines, alter screenshot tolerances, or authorize a merge or deployment." +} diff --git a/e2e/visual-regression.spec.ts b/e2e/visual-regression.spec.ts index a3d8aaa4..bf183678 100644 --- a/e2e/visual-regression.spec.ts +++ b/e2e/visual-regression.spec.ts @@ -11,7 +11,10 @@ import { test, expect } from "@playwright/test"; -const BASE_URL = "http://localhost:3000"; +// The release-grade E2E matrix supplies an isolated base URL for every shard. +// Retain a local default for direct developer invocation, but never hard-code a +// host that bypasses the shard's explicitly provisioned application instance. +const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; // Pages to snapshot — covers the most visually complex and business-critical pages const SNAPSHOT_PAGES = [ diff --git a/infra/grafana/dashboards/ndsep-webhook-delivery-worker.json b/infra/grafana/dashboards/ndsep-webhook-delivery-worker.json new file mode 100644 index 00000000..ccb55412 --- /dev/null +++ b/infra/grafana/dashboards/ndsep-webhook-delivery-worker.json @@ -0,0 +1,197 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "NDSEP Prometheus data source", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "description": "NDSEP webhook active queue worker: queue admission state and aggregate worker outcomes. This dashboard is observability configuration only and does not authorize active traffic.", + "editable": true, + "graphTooltip": 1, + "id": null, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "Active Queue Safety State", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "ndsep_webhook_queue_active_mode", + "legendFormat": "Active mode" + } + ], + "title": "Active Mode (1 = Enabled)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "ops" } }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_queue_active_enqueued_total[5m]))", + "legendFormat": "Accepted intents / 5m" + } + ], + "title": "Durable Active Queue Admission", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "ops" } }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_queue_worker_claimed_total[5m]))", + "legendFormat": "Claims / 5m" + } + ], + "title": "Worker Claims", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "ops" } }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_queue_worker_lease_recoveries_total[5m]))", + "legendFormat": "Recovered leases / 5m" + } + ], + "title": "Lease Recoveries", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 10, + "title": "Worker Delivery Outcomes", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "ops", "color": { "mode": "palette-classic" } } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 11, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(ndsep_webhook_queue_worker_delivered_total[5m]))", + "legendFormat": "delivered / s" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(ndsep_webhook_queue_worker_retried_total[5m]))", + "legendFormat": "retried / s" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(ndsep_webhook_queue_worker_dead_total[5m]))", + "legendFormat": "dead / s" + } + ], + "title": "Terminal and Retry Outcome Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "ops", "color": { "mode": "palette-classic" } } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 12, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_queue_worker_claim_errors_total[5m]))", + "legendFormat": "claim errors / 5m" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_queue_worker_finalization_errors_total[5m]))", + "legendFormat": "finalization errors / 5m" + } + ], + "title": "Queue Claim and Finalization Errors", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["ndsep", "webhook", "active-queue", "worker"], + "time": { "from": "now-6h", "to": "now" }, + "timezone": "Africa/Lagos", + "title": "NDSEP Webhook Active Queue Worker", + "uid": "ndsep-webhook-delivery-worker-v1", + "version": 1 +} diff --git a/infra/grafana/dashboards/ndsep-webhook-shadow.json b/infra/grafana/dashboards/ndsep-webhook-shadow.json new file mode 100644 index 00000000..90ef098b --- /dev/null +++ b/infra/grafana/dashboards/ndsep-webhook-shadow.json @@ -0,0 +1,223 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "NDSEP Prometheus data source", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "description": "NDSEP protected-staging webhook shadow queue: durable intent, canonical finalization, and telemetry freshness. This dashboard does not authorize queue-worker activation.", + "editable": true, + "graphTooltip": 1, + "id": null, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "Shadow Queue Safety State", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "ndsep_webhook_shadow_mode", + "legendFormat": "Shadow mode" + } + ], + "title": "Shadow Mode (1 = Enabled)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "ops" } }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_shadow_enqueued_total[5m]))", + "legendFormat": "Durable intents / 5m" + } + ], + "title": "Durable Shadow Intents", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.05 }, + { "color": "red", "value": 0.1 } + ] + } + } + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_shadow_finalizations_total{outcome=\"dead\"}[5m])) / clamp_min(sum(increase(ndsep_webhook_shadow_finalizations_total[5m])), 1)", + "legendFormat": "Dead finalization ratio" + } + ], + "title": "Terminal Error Ratio (5m)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 120 } + ] + } + } + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 }, + "id": 5, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { "calcs": ["lastNotNull"] } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "time() - ndsep_webhook_shadow_last_enqueued_unixtime", + "legendFormat": "Seconds since last durable intent" + } + ], + "title": "Telemetry Freshness", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 10, + "title": "Durability and Reconciliation Signals", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "ops", "color": { "mode": "palette-classic" } } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 11, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(ndsep_webhook_shadow_enqueued_total[5m]))", + "legendFormat": "enqueued / s" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(ndsep_webhook_shadow_finalizations_total{outcome=\"delivered\"}[5m]))", + "legendFormat": "finalized delivered / s" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(ndsep_webhook_shadow_finalizations_total{outcome=\"dead\"}[5m]))", + "legendFormat": "finalized dead / s" + } + ], + "title": "Shadow Intent and Finalization Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "ops", "color": { "mode": "palette-classic" } } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 12, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_shadow_enqueue_errors_total[5m]))", + "legendFormat": "enqueue errors / 5m" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(increase(ndsep_webhook_shadow_finalization_errors_total[5m]))", + "legendFormat": "finalization errors / 5m" + } + ], + "title": "Shadow Persistence Errors", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["ndsep", "webhook", "shadow", "staging"], + "time": { "from": "now-6h", "to": "now" }, + "timezone": "Africa/Lagos", + "title": "NDSEP Protected-Staging Webhook Shadow Queue", + "uid": "ndsep-webhook-shadow-v1", + "version": 1 +} diff --git a/infra/grafana/provisioning/dashboards/ndsep-webhook-shadow.yml b/infra/grafana/provisioning/dashboards/ndsep-webhook-shadow.yml new file mode 100644 index 00000000..c685d6dd --- /dev/null +++ b/infra/grafana/provisioning/dashboards/ndsep-webhook-shadow.yml @@ -0,0 +1,11 @@ +apiVersion: 1 +providers: + - name: ndsep-webhook-shadow + orgId: 1 + folder: NDSEP Operations + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards diff --git a/infra/k8s/keda-scaledobjects.yaml b/infra/k8s/keda-scaledobjects.yaml index 53a87a43..6169abfe 100644 --- a/infra/k8s/keda-scaledobjects.yaml +++ b/infra/k8s/keda-scaledobjects.yaml @@ -812,3 +812,56 @@ spec: offsetResetPolicy: latest authenticationRef: name: kafka-trigger-auth + +--- +# ─── Webhook Delivery Queue Worker: PostgreSQL claimable queue depth ─────────── +# This object requires the KEDA PostgreSQL scaler CRD and uses the Deployment's +# NDSEP_PG_SCALER_URL secret-derived environment variable. It is not active until +# the digest-bound worker Deployment has been rendered and applied by an approved +# GitOps release process. +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: webhook-delivery-worker-keda + namespace: ndsep + labels: + app: webhook-delivery-worker + domain: platform + scaler: keda +spec: + scaleTargetRef: + name: webhook-delivery-worker + envSourceContainerName: webhook-delivery-worker + pollingInterval: 15 + cooldownPeriod: 300 + minReplicaCount: 2 + maxReplicaCount: 20 + fallback: + failureThreshold: 3 + replicas: 2 + triggers: + - type: postgresql + metadata: + connectionFromEnv: NDSEP_PG_SCALER_URL + query: | + SELECT COALESCE(count(*), 0)::numeric + FROM webhook_delivery_attempts + WHERE status IN ('pending', 'retrying') + AND next_retry_at <= now() + targetQueryValue: "50" + activationTargetQueryValue: "1" + advanced: + horizontalPodAutoscalerConfig: + behavior: + scaleUp: + stabilizationWindowSeconds: 30 + policies: + - type: Pods + value: 4 + periodSeconds: 30 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 60 diff --git a/infra/k8s/ml-foundation-candidate-cronjob.yaml b/infra/k8s/ml-foundation-candidate-cronjob.yaml new file mode 100644 index 00000000..745420db --- /dev/null +++ b/infra/k8s/ml-foundation-candidate-cronjob.yaml @@ -0,0 +1,128 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ndsep-ml-candidate-trainer + namespace: ndsep-staging + labels: + app.kubernetes.io/name: ndsep-ml-foundation + ndsep.ai/mode: candidate-only +automountServiceAccountToken: false +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: ndsep-ml-candidate-training + namespace: ndsep-staging + labels: + app.kubernetes.io/name: ndsep-ml-foundation + ndsep.ai/mode: candidate-only +spec: + # The job is intentionally disabled until data/model-risk approval, immutable + # image digest and storage controls are separately accepted. + suspend: true + schedule: "0 2 * * 0" + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 3 + successfulJobsHistoryLimit: 4 + jobTemplate: + spec: + backoffLimit: 0 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: ndsep-ml-foundation + ndsep.ai/mode: candidate-only + spec: + serviceAccountName: ndsep-ml-candidate-trainer + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: candidate-trainer + # Release automation must replace this deliberately invalid marker + # with a directly scanned, signed and admitted immutable digest. + image: REPLACE_WITH_SIGNED_ML_FOUNDATION_DIGEST + imagePullPolicy: IfNotPresent + command: ["python3", "continuous_candidate.py"] + args: + - --lakehouse-dir + - /var/lib/ndsep-ml/lakehouse + - --model-output-dir + - /var/lib/ndsep-ml/candidate-models + - --run-evidence-dir + - /var/lib/ndsep-ml/evidence + - --signing-key + - /var/run/ndsep-model-signer/candidate-model-signing-key.pem + - --events + - "6000" + - --tenants + - "12" + - --days + - "180" + - --seed + - "20260830" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: "1" + memory: 2Gi + limits: + cpu: "2" + memory: 4Gi + volumeMounts: + - name: lakehouse + mountPath: /var/lib/ndsep-ml/lakehouse + readOnly: false + - name: candidate-models + mountPath: /var/lib/ndsep-ml/candidate-models + readOnly: false + - name: evidence + mountPath: /var/lib/ndsep-ml/evidence + readOnly: false + - name: signer-key + mountPath: /var/run/ndsep-model-signer + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: lakehouse + persistentVolumeClaim: + claimName: ndsep-synthetic-lakehouse-pvc + - name: candidate-models + persistentVolumeClaim: + claimName: ndsep-candidate-models-pvc + - name: evidence + persistentVolumeClaim: + claimName: ndsep-ml-candidate-evidence-pvc + - name: signer-key + secret: + secretName: ndsep-ml-candidate-signing + defaultMode: 0400 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 128Mi +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: ndsep-ml-candidate-training-no-egress + namespace: ndsep-staging +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: ndsep-ml-foundation + ndsep.ai/mode: candidate-only + policyTypes: ["Ingress", "Egress"] + ingress: [] + egress: [] diff --git a/infra/k8s/webhook-delivery-worker.yaml.tmpl b/infra/k8s/webhook-delivery-worker.yaml.tmpl new file mode 100644 index 00000000..df539237 --- /dev/null +++ b/infra/k8s/webhook-delivery-worker.yaml.tmpl @@ -0,0 +1,144 @@ +# Render only through scripts/ci/render-webhook-delivery-worker-manifest.sh. +# The renderer rejects unset or mutable image references. Do not apply this template directly. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: webhook-delivery-worker + namespace: ndsep +automountServiceAccountToken: false +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webhook-delivery-worker + namespace: ndsep + labels: + app: webhook-delivery-worker + tier: worker + delivery-mode: active +spec: + replicas: 2 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: webhook-delivery-worker + template: + metadata: + labels: + app: webhook-delivery-worker + tier: worker + delivery-mode: active + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8094" + prometheus.io/path: "/metrics" + spec: + serviceAccountName: webhook-delivery-worker + automountServiceAccountToken: false + terminationGracePeriodSeconds: 120 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: webhook-delivery-worker + image: ${NDSEP_WEBHOOK_DELIVERY_IMAGE} + imagePullPolicy: IfNotPresent + command: ["/app/webhook_delivery"] + env: + - name: WEBHOOK_DELIVERY_QUEUE_MODE + value: "active" + - name: WEBHOOK_WORKER_ID + valueFrom: + fieldRef: + fieldPath: metadata.uid + - name: WEBHOOK_WORKER_BATCH_SIZE + value: "50" + - name: WEBHOOK_WORKER_POLL_INTERVAL + value: "5s" + - name: WEBHOOK_WORKER_LEASE_DURATION + value: "90s" + - name: WEBHOOK_WORKER_HTTP_TIMEOUT + value: "10s" + - name: WEBHOOK_WORKER_METRICS_ADDRESS + value: ":8094" + - name: NDSEP_PG_URL + valueFrom: + secretKeyRef: + name: webhook-delivery-worker-db + key: url + - name: NDSEP_PG_SCALER_URL + valueFrom: + secretKeyRef: + name: webhook-delivery-worker-scaler-db + key: url + ports: + - name: metrics + containerPort: 8094 + readinessProbe: + httpGet: + path: /health + port: metrics + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: metrics + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 3 + failureThreshold: 3 + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep 10"] + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "1" + memory: "1Gi" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +--- +apiVersion: v1 +kind: Service +metadata: + name: webhook-delivery-worker + namespace: ndsep + labels: + app: webhook-delivery-worker +spec: + selector: + app: webhook-delivery-worker + ports: + - name: metrics + port: 8094 + targetPort: metrics +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: webhook-delivery-worker + namespace: ndsep +spec: + minAvailable: 1 + selector: + matchLabels: + app: webhook-delivery-worker diff --git a/infra/prometheus/alertmanager.yml b/infra/prometheus/alertmanager.yml index 142ef8ae..04ffe8f9 100644 --- a/infra/prometheus/alertmanager.yml +++ b/infra/prometheus/alertmanager.yml @@ -3,23 +3,20 @@ # ============================================ # Routes alerts to Slack, PagerDuty, and email based on severity and team. # -# Environment variables (set in .env.production): -# SLACK_WEBHOOK_URL — Slack Incoming Webhook URL -# PAGERDUTY_INTEGRATION_KEY — PagerDuty Events API v2 key -# NITDA_COMPLIANCE_EMAIL — Compliance team email -# SMTP_HOST — SMTP server hostname -# SMTP_FROM — Sender email address +# This is a template. The `make docker-prod-up` path renders it with required +# approved values from the designated production environment file into a mode-0600 +# runtime file. Do not deploy this unrendered template. ############################################################################### global: # How long to wait before sending a notification again if it has already been sent resolve_timeout: 5m # SMTP defaults (used by email receiver) - smtp_smarthost: "${SMTP_HOST:-smtp.gmail.com:587}" - smtp_from: "${SMTP_FROM:-ndsep-alerts@nitda.gov.ng}" + smtp_smarthost: "__NDSEP_SMTP_HOST__" + smtp_from: "__NDSEP_SMTP_FROM__" smtp_require_tls: true # Slack defaults - slack_api_url: "${SLACK_WEBHOOK_URL:-https://hooks.slack.com/services/PLACEHOLDER}" + slack_api_url: "__NDSEP_SLACK_WEBHOOK_URL__" # HTTP client settings http_config: follow_redirects: true @@ -45,7 +42,7 @@ route: group_wait: 10s group_interval: 2m repeat_interval: 1h - continue: true # Also send to Slack + continue: true # Also send to Slack - match: severity: critical @@ -111,13 +108,12 @@ inhibit_rules: # ─── Receivers ──────────────────────────────────────────────────────────────── receivers: - # ── Slack: General Platform Channel ───────────────────────────────────────── - name: slack-general slack_configs: - channel: "#ndsep-alerts" send_resolved: true - title: "{{ if eq .Status \"firing\" }}🔴{{ else }}✅{{ end }} [{{ .Status | toUpper }}] {{ .GroupLabels.alertname }}" + title: '{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} [{{ .Status | toUpper }}] {{ .GroupLabels.alertname }}' text: | *Severity:* {{ .CommonLabels.severity | toUpper }} *Team:* {{ .CommonLabels.team }} @@ -126,12 +122,12 @@ receivers: *Description:* {{ .Annotations.description }} *Started:* {{ .StartsAt | since }} {{ end }} - color: "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}" + color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}' footer: "NDSEP Monitoring | {{ .CommonLabels.instance }}" actions: - type: button text: "View Dashboard" - url: "${PLATFORM_URL:-https://ndsep.nitda.gov.ng}/metrics" + url: "__NDSEP_PLATFORM_URL__/metrics" # ── Slack: Critical Alerts Channel ─────────────────────────────────────────── - name: slack-critical @@ -166,7 +162,7 @@ receivers: actions: - type: button text: "View Compliance Dashboard" - url: "${PLATFORM_URL:-https://ndsep.nitda.gov.ng}/compliance" + url: "__NDSEP_PLATFORM_URL__/compliance" # ── Slack: Security Channel ─────────────────────────────────────────────── - name: slack-security @@ -180,12 +176,12 @@ receivers: *Description:* {{ .Annotations.description }} *Started:* {{ .StartsAt | since }} {{ end }} - color: "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}" + color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}' # ── PagerDuty: Critical Escalation ─────────────────────────────────────── - name: pagerduty-critical pagerduty_configs: - - routing_key: "${PAGERDUTY_INTEGRATION_KEY:-PLACEHOLDER_KEY}" + - routing_key: "__NDSEP_PAGERDUTY_INTEGRATION_KEY__" send_resolved: true severity: "{{ .CommonLabels.severity }}" description: "{{ .GroupLabels.alertname }}: {{ .CommonAnnotations.summary }}" @@ -196,13 +192,13 @@ receivers: platform: "NDSEP" environment: "production" links: - - href: "${PLATFORM_URL:-https://ndsep.nitda.gov.ng}/metrics" + - href: "__NDSEP_PLATFORM_URL__/metrics" text: "View NDSEP Dashboard" # ── Email: Compliance Team ──────────────────────────────────────────────── - name: email-compliance email_configs: - - to: "${NITDA_COMPLIANCE_EMAIL:-compliance@nitda.gov.ng}" + - to: "__NDSEP_NITDA_COMPLIANCE_EMAIL__" send_resolved: true headers: Subject: "[NDSEP Alert] {{ .GroupLabels.alertname }} — {{ .Status | toUpper }}" @@ -215,4 +211,4 @@ receivers:

Description: {{ .Annotations.description }}

Started: {{ .StartsAt }}

{{ end }} -

View Compliance Dashboard

+

View Compliance Dashboard

diff --git a/infra/prometheus/alerts.yml b/infra/prometheus/alerts.yml index 75d52e65..649976e5 100644 --- a/infra/prometheus/alerts.yml +++ b/infra/prometheus/alerts.yml @@ -4,12 +4,10 @@ ############################################################################### groups: - # ─── API / Application ───────────────────────────────────────────────────── - name: ndsep-api interval: 30s rules: - - alert: APIDown expr: up{job="ndsep-api"} == 0 for: 1m @@ -49,7 +47,6 @@ groups: - name: ndsep-database interval: 30s rules: - - alert: PostgresDown expr: up{job="postgres"} == 0 for: 1m @@ -96,7 +93,6 @@ groups: - name: ndsep-workers interval: 60s rules: - - alert: WorkerDown expr: up{job=~"ndsep-worker-.*"} == 0 for: 5m @@ -117,11 +113,127 @@ groups: summary: "Kafka consumer lag is high" description: "Consumer group {{ $labels.consumergroup }} lag: {{ $value }} messages." + # ─── Webhook shadow queue ────────────────────────────────────────────────── + - name: ndsep-webhook-shadow + interval: 30s + rules: + - alert: WebhookShadowQueueIntentFailure + expr: | + max(ndsep_webhook_shadow_mode) == 1 + and sum(increase(ndsep_webhook_shadow_enqueue_errors_total[5m])) > 0 + for: 2m + labels: + severity: critical + team: platform + annotations: + summary: "Webhook shadow queue cannot persist delivery intent" + description: "One or more shadow queue intents failed to persist over 5 minutes while shadow mode is enabled. Keep worker claiming disabled, investigate PostgreSQL availability/constraints, and reconcile the canonical ledger before any cutover." + + - alert: WebhookShadowQueueFinalizationFailure + expr: | + max(ndsep_webhook_shadow_mode) == 1 + and sum(increase(ndsep_webhook_shadow_finalization_errors_total[5m])) > 0 + for: 2m + labels: + severity: critical + team: platform + annotations: + summary: "Webhook shadow queue finalization diverged from the canonical ledger" + description: "A canonical webhook outcome was recorded but its shadow queue row could not be finalized. Keep worker claiming disabled and run the read-only reconciliation check for the approved canary window." + + - alert: WebhookShadowTerminalErrorRateHigh + expr: | + max(ndsep_webhook_shadow_mode) == 1 + and sum(increase(ndsep_webhook_shadow_finalizations_total{outcome="dead"}[5m])) >= 5 + and ( + sum(increase(ndsep_webhook_shadow_finalizations_total{outcome="dead"}[5m])) + / + clamp_min(sum(increase(ndsep_webhook_shadow_finalizations_total[5m])), 1) + ) > 0.05 + for: 5m + labels: + severity: warning + team: platform + annotations: + summary: "Webhook shadow terminal error rate exceeds 5%" + description: "More than 5% of finalized shadow delivery rows are dead over 5 minutes, with at least five terminal failures. Pause canary advancement and inspect remote delivery failures plus canonical-ledger reconciliation." + + - alert: WebhookShadowMetricsUnavailable + expr: | + max_over_time(ndsep_webhook_shadow_mode[2m]) == 1 + and absent_over_time(ndsep_webhook_shadow_enqueued_total[2m]) + for: 2m + labels: + severity: warning + team: platform + annotations: + summary: "Webhook shadow telemetry is unavailable" + description: "Shadow mode is active but the API metrics scrape no longer exposes webhook shadow counters. Do not accept a canary window without observable queue evidence." + + # ─── Webhook active queue worker ─────────────────────────────────────────── + - name: ndsep-webhook-active-worker + interval: 30s + rules: + - alert: WebhookActiveQueueAdmissionFailure + expr: | + max(ndsep_webhook_queue_active_mode) == 1 + and sum(increase(ndsep_webhook_shadow_enqueue_errors_total[5m])) > 0 + for: 2m + labels: + severity: critical + team: platform + annotations: + summary: "Webhook active queue cannot persist delivery intent" + description: "Active queue admission recorded persistence failures over five minutes. Hold cutover, investigate PostgreSQL durability, and reconcile queue intent before resuming worker processing." + + - alert: WebhookActiveWorkerFinalizationFailure + expr: | + max(ndsep_webhook_queue_active_mode) == 1 + and sum(increase(ndsep_webhook_queue_worker_finalization_errors_total[5m])) > 0 + for: 2m + labels: + severity: critical + team: platform + annotations: + summary: "Webhook active worker cannot atomically finalize delivery outcome" + description: "A worker finalization transaction failed. Inspect lease, canonical-ledger, and PostgreSQL error evidence before any rollout progression." + + - alert: WebhookActiveWorkerMetricsUnavailable + expr: | + max_over_time(ndsep_webhook_queue_active_mode[2m]) == 1 + and absent_over_time(ndsep_webhook_queue_worker_claimed_total[2m]) + for: 2m + labels: + severity: warning + team: platform + annotations: + summary: "Webhook active worker telemetry is unavailable" + description: "API active mode is enabled but the worker metrics endpoint has not been observed. Do not treat queue execution as healthy without worker telemetry." + + - alert: WebhookActiveTerminalFailureRateHigh + expr: | + sum(increase(ndsep_webhook_queue_worker_dead_total[5m])) >= 5 + and ( + sum(increase(ndsep_webhook_queue_worker_dead_total[5m])) + / + clamp_min( + sum(increase(ndsep_webhook_queue_worker_dead_total[5m])) + + sum(increase(ndsep_webhook_queue_worker_delivered_total[5m])), + 1 + ) + ) > 0.05 + for: 5m + labels: + severity: warning + team: platform + annotations: + summary: "Webhook active worker terminal failure rate exceeds 5%" + description: "At least five terminal webhook failures and a terminal dead-outcome ratio above 5% were observed over five minutes. Pause rollout advancement and investigate receiver, lease, and canonical-ledger evidence." + # ─── Compliance SLA ──────────────────────────────────────────────────────── - name: ndsep-compliance interval: 300s rules: - - alert: CitizenRequestSLAMetricUnavailable expr: ndsep_citizen_request_sla_metric_available != 1 for: 2m @@ -157,7 +269,6 @@ groups: - name: ndsep-security interval: 60s rules: - - alert: HighAuthFailureRate expr: | rate(ndsep_auth_failures_total[5m]) > 0.5 @@ -263,7 +374,6 @@ groups: - name: ndsep-infra interval: 60s rules: - - alert: HighCPUUsage expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85 for: 10m diff --git a/mobile/docker-compose.portable.yml b/mobile/docker-compose.portable.yml index 6a3b3bd5..0ce2405f 100644 --- a/mobile/docker-compose.portable.yml +++ b/mobile/docker-compose.portable.yml @@ -4,7 +4,7 @@ services: app: image: node:22-bookworm working_dir: /workspace - command: sh -c "corepack enable && pnpm install && pnpm dev:server" + command: sh -c "corepack enable && pnpm install --frozen-lockfile && pnpm dev:server" volumes: - ./:/workspace ports: @@ -13,29 +13,35 @@ services: env_file: - .env depends_on: - - mysql - - ollama + postgresql: + condition: service_healthy + ollama: + condition: service_started restart: unless-stopped - mysql: - image: mysql:8.4 + postgresql: + image: postgres:16.4-alpine environment: - MYSQL_DATABASE: idlr_pts - MYSQL_USER: idlr - MYSQL_PASSWORD: idlrpass - MYSQL_ROOT_PASSWORD: rootpass + POSTGRES_DB: ${MOBILE_POSTGRES_DB:?MOBILE_POSTGRES_DB is required} + POSTGRES_USER: ${MOBILE_POSTGRES_USER:?MOBILE_POSTGRES_USER is required} + POSTGRES_PASSWORD: ${MOBILE_POSTGRES_PASSWORD:?MOBILE_POSTGRES_PASSWORD is required} ports: - - "3306:3306" + - "5432:5432" volumes: - - mysql_data:/var/lib/mysql + - postgresql_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 10 restart: unless-stopped minio: image: minio/minio:latest command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} ports: - "9000:9000" - "9001:9001" @@ -52,6 +58,6 @@ services: restart: unless-stopped volumes: - mysql_data: + postgresql_data: minio_data: ollama_data: diff --git a/mobile/docs/portable-deployment.md b/mobile/docs/portable-deployment.md index 027f4484..066296bd 100644 --- a/mobile/docs/portable-deployment.md +++ b/mobile/docs/portable-deployment.md @@ -15,7 +15,7 @@ This platform has been refactored toward a **portable stack** so it can run outs | Variable | Purpose | | --- | --- | -| `DATABASE_URL` | MySQL connection string for Drizzle | +| `DATABASE_URL` | PostgreSQL connection string for Drizzle, using `postgresql://` or `postgres://` | | `JWT_SECRET` or `SESSION_SECRET` | Secret used to sign local JWT sessions | | `APP_ID` | Logical app identifier used in local sessions | | `AUTH_ISSUER` | JWT issuer string for local auth | @@ -32,7 +32,7 @@ This platform has been refactored toward a **portable stack** so it can run outs The backend can run as a standard Node.js service behind Nginx, Caddy, or Traefik. Uploaded files are served from `server/uploads/` locally or mapped to external object storage. For AI extraction, start with **Ollama** for local development and move to a compatible provider only if you need larger multimodal models. -A starter orchestration file is included at `docker-compose.portable.yml`. It brings up the app, MySQL, MinIO, and Ollama so teams can reproduce the portable baseline quickly in non-Manus environments. +A starter orchestration file is included at `docker-compose.portable.yml`. It brings up the app, PostgreSQL, MinIO, and Ollama so teams can reproduce the portable baseline quickly in non-Manus environments. The file requires explicit PostgreSQL and object-storage credentials; it supplies no default database credential. ## Suggested next infrastructure steps diff --git a/mobile/docs/production-checklist.md b/mobile/docs/production-checklist.md index a9ff5455..f34f2ae5 100644 --- a/mobile/docs/production-checklist.md +++ b/mobile/docs/production-checklist.md @@ -7,7 +7,7 @@ This checklist is intended for teams deploying the permitting platform outside t | Area | Minimum action | | --- | --- | | Backend API | Run the Express and tRPC backend behind Caddy, Nginx, or Traefik with TLS enabled. | -| Database | Provision a managed MySQL instance or migrate the schema to the target production database before launch. | +| Database | Provision managed PostgreSQL, apply the reviewed PostgreSQL migrations, enforce TLS and least-privilege credentials, and complete backup/restore acceptance before launch. | | Storage | Replace local uploads with MinIO or another S3-compatible object store for durable evidence retention. | | AI runtime | Pin the configured `AI_MODEL` and verify extraction latency for permit uploads under production load. | | Mobile and PWA config | Set `EXPO_PUBLIC_API_BASE_URL`, `EXPO_PUBLIC_APP_SCHEME`, and `APP_BUNDLE_ID` explicitly for each environment. | diff --git a/mobile/drizzle.config.ts b/mobile/drizzle.config.ts index 321752c1..08a03817 100644 --- a/mobile/drizzle.config.ts +++ b/mobile/drizzle.config.ts @@ -8,7 +8,7 @@ if (!connectionString) { export default defineConfig({ schema: "./drizzle/schema.ts", out: "./drizzle", - dialect: "mysql", + dialect: "postgresql", dbCredentials: { url: connectionString, }, diff --git a/mobile/drizzle/0000_elite_eternals.sql b/mobile/drizzle/0000_elite_eternals.sql deleted file mode 100644 index d0cd6ebc..00000000 --- a/mobile/drizzle/0000_elite_eternals.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE `users` ( - `id` int AUTO_INCREMENT NOT NULL, - `openId` varchar(64) NOT NULL, - `name` text, - `email` varchar(320), - `loginMethod` varchar(64), - `role` enum('user','admin') NOT NULL DEFAULT 'user', - `createdAt` timestamp NOT NULL DEFAULT (now()), - `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, - `lastSignedIn` timestamp NOT NULL DEFAULT (now()), - CONSTRAINT `users_id` PRIMARY KEY(`id`), - CONSTRAINT `users_openId_unique` UNIQUE(`openId`) -); diff --git a/mobile/drizzle/0000_postgresql_baseline.sql b/mobile/drizzle/0000_postgresql_baseline.sql new file mode 100644 index 00000000..f8440120 --- /dev/null +++ b/mobile/drizzle/0000_postgresql_baseline.sql @@ -0,0 +1,119 @@ +CREATE TYPE "public"."mobile_middleware_status" AS ENUM('planned', 'connected', 'degraded');--> statement-breakpoint +CREATE TYPE "public"."mobile_user_role" AS ENUM('user', 'admin');--> statement-breakpoint +CREATE TYPE "public"."mobile_obligation_status" AS ENUM('pending', 'satisfied', 'at_risk');--> statement-breakpoint +CREATE TYPE "public"."mobile_permit_priority" AS ENUM('routine', 'elevated', 'critical');--> statement-breakpoint +CREATE TYPE "public"."mobile_permit_sector" AS ENUM('mining', 'oil_gas', 'multi_agency');--> statement-breakpoint +CREATE TYPE "public"."mobile_permit_stage" AS ENUM('intake', 'spatial_clearance', 'technical_review', 'environmental_review', 'agency_coordination', 'payment_pending', 'approval', 'issued', 'active_monitoring');--> statement-breakpoint +CREATE TYPE "public"."mobile_service_health" AS ENUM('healthy', 'warning');--> statement-breakpoint +CREATE TYPE "public"."mobile_service_language" AS ENUM('typescript', 'python', 'go', 'rust');--> statement-breakpoint +CREATE TYPE "public"."mobile_service_runtime_mode" AS ENUM('webdev_backend', 'external_service', 'reserved_worker');--> statement-breakpoint +CREATE TABLE "middlewareComponents" ( + "key" varchar(80) PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "purpose" text NOT NULL, + "status" "mobile_middleware_status" NOT NULL, + "ownerService" varchar(120) NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "permitCases" ( + "id" varchar(80) PRIMARY KEY NOT NULL, + "sector" "mobile_permit_sector" NOT NULL, + "permitType" varchar(255) NOT NULL, + "title" varchar(255) NOT NULL, + "applicantName" varchar(255) NOT NULL, + "locationLabel" varchar(255) NOT NULL, + "assetReference" varchar(120) NOT NULL, + "stage" "mobile_permit_stage" NOT NULL, + "priority" "mobile_permit_priority" NOT NULL, + "leadAgencyId" varchar(80) NOT NULL, + "participatingAgencyIds" jsonb NOT NULL, + "summary" text NOT NULL, + "timeline" jsonb NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "permitObligations" ( + "id" varchar(80) PRIMARY KEY NOT NULL, + "permitCaseId" varchar(80) NOT NULL, + "title" varchar(255) NOT NULL, + "dueAt" timestamp with time zone NOT NULL, + "status" "mobile_obligation_status" NOT NULL, + "owner" varchar(255) NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "permittingAgencies" ( + "id" varchar(80) PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "role" text NOT NULL, + "jurisdiction" varchar(120) NOT NULL, + "reviewSlaHours" integer NOT NULL, + "queueDepth" integer NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "serviceTopology" ( + "id" varchar(80) PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "language" "mobile_service_language" NOT NULL, + "responsibility" text NOT NULL, + "runtimeMode" "mobile_service_runtime_mode" NOT NULL, + "endpointPath" varchar(255) NOT NULL, + "health" "mobile_service_health" NOT NULL, + "middlewareKeys" jsonb NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY NOT NULL, + "openId" varchar(64) NOT NULL, + "name" text, + "email" varchar(320), + "loginMethod" varchar(64), + "role" "mobile_user_role" DEFAULT 'user' NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + "lastSignedIn" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_openId_unique" UNIQUE("openId") +); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION "mobile_touch_updated_at"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW."updatedAt" = NOW(); + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER "mobile_users_touch_updated_at" +BEFORE UPDATE ON "users" +FOR EACH ROW EXECUTE FUNCTION "mobile_touch_updated_at"(); +--> statement-breakpoint +CREATE TRIGGER "mobile_middleware_components_touch_updated_at" +BEFORE UPDATE ON "middlewareComponents" +FOR EACH ROW EXECUTE FUNCTION "mobile_touch_updated_at"(); +--> statement-breakpoint +CREATE TRIGGER "mobile_permit_cases_touch_updated_at" +BEFORE UPDATE ON "permitCases" +FOR EACH ROW EXECUTE FUNCTION "mobile_touch_updated_at"(); +--> statement-breakpoint +CREATE TRIGGER "mobile_permit_obligations_touch_updated_at" +BEFORE UPDATE ON "permitObligations" +FOR EACH ROW EXECUTE FUNCTION "mobile_touch_updated_at"(); +--> statement-breakpoint +CREATE TRIGGER "mobile_permitting_agencies_touch_updated_at" +BEFORE UPDATE ON "permittingAgencies" +FOR EACH ROW EXECUTE FUNCTION "mobile_touch_updated_at"(); +--> statement-breakpoint +CREATE TRIGGER "mobile_service_topology_touch_updated_at" +BEFORE UPDATE ON "serviceTopology" +FOR EACH ROW EXECUTE FUNCTION "mobile_touch_updated_at"(); diff --git a/mobile/drizzle/0001_brown_nico_minoru.sql b/mobile/drizzle/0001_brown_nico_minoru.sql deleted file mode 100644 index f17c1ae2..00000000 --- a/mobile/drizzle/0001_brown_nico_minoru.sql +++ /dev/null @@ -1,68 +0,0 @@ -CREATE TABLE `middlewareComponents` ( - `key` varchar(80) NOT NULL, - `name` varchar(255) NOT NULL, - `purpose` text NOT NULL, - `status` enum('planned','connected','degraded') NOT NULL, - `ownerService` varchar(120) NOT NULL, - `createdAt` timestamp NOT NULL DEFAULT (now()), - `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT `middlewareComponents_key` PRIMARY KEY(`key`) -); ---> statement-breakpoint -CREATE TABLE `permitCases` ( - `id` varchar(80) NOT NULL, - `sector` enum('mining','oil_gas','multi_agency') NOT NULL, - `permitType` varchar(255) NOT NULL, - `title` varchar(255) NOT NULL, - `applicantName` varchar(255) NOT NULL, - `locationLabel` varchar(255) NOT NULL, - `assetReference` varchar(120) NOT NULL, - `stage` enum('intake','spatial_clearance','technical_review','environmental_review','agency_coordination','payment_pending','approval','issued','active_monitoring') NOT NULL, - `priority` enum('routine','elevated','critical') NOT NULL, - `leadAgencyId` varchar(80) NOT NULL, - `participatingAgencyIds` json NOT NULL, - `summary` text NOT NULL, - `timeline` json NOT NULL, - `createdAt` timestamp NOT NULL DEFAULT (now()), - `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT `permitCases_id` PRIMARY KEY(`id`) -); ---> statement-breakpoint -CREATE TABLE `permitObligations` ( - `id` varchar(80) NOT NULL, - `permitCaseId` varchar(80) NOT NULL, - `title` varchar(255) NOT NULL, - `dueAt` timestamp NOT NULL, - `status` enum('pending','satisfied','at_risk') NOT NULL, - `owner` varchar(255) NOT NULL, - `createdAt` timestamp NOT NULL DEFAULT (now()), - `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT `permitObligations_id` PRIMARY KEY(`id`) -); ---> statement-breakpoint -CREATE TABLE `permittingAgencies` ( - `id` varchar(80) NOT NULL, - `name` varchar(255) NOT NULL, - `role` text NOT NULL, - `jurisdiction` varchar(120) NOT NULL, - `reviewSlaHours` int NOT NULL, - `queueDepth` int NOT NULL, - `active` int NOT NULL DEFAULT 1, - `createdAt` timestamp NOT NULL DEFAULT (now()), - `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT `permittingAgencies_id` PRIMARY KEY(`id`) -); ---> statement-breakpoint -CREATE TABLE `serviceTopology` ( - `id` varchar(80) NOT NULL, - `name` varchar(255) NOT NULL, - `language` enum('typescript','python','go','rust') NOT NULL, - `responsibility` text NOT NULL, - `runtimeMode` enum('webdev_backend','external_service','reserved_worker') NOT NULL, - `endpointPath` varchar(255) NOT NULL, - `health` enum('healthy','warning') NOT NULL, - `middlewareKeys` json NOT NULL, - `createdAt` timestamp NOT NULL DEFAULT (now()), - `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT `serviceTopology_id` PRIMARY KEY(`id`) -); diff --git a/mobile/drizzle/meta/0000_snapshot.json b/mobile/drizzle/meta/0000_snapshot.json index 8641fe48..f5b693f2 100644 --- a/mobile/drizzle/meta/0000_snapshot.json +++ b/mobile/drizzle/meta/0000_snapshot.json @@ -1,106 +1,564 @@ { - "version": "5", - "dialect": "mysql", - "id": "3c3a03ea-b871-416a-b531-aa772cca8b00", + "id": "1d3dd139-69ce-405a-bbe3-8a932b543057", "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", "tables": { - "users": { - "name": "users", + "public.middlewareComponents": { + "name": "middlewareComponents", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "mobile_middleware_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ownerService": { + "name": "ownerService", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permitCases": { + "name": "permitCases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "sector": { + "name": "sector", + "type": "mobile_permit_sector", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permitType": { + "name": "permitType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applicantName": { + "name": "applicantName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "locationLabel": { + "name": "locationLabel", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "assetReference": { + "name": "assetReference", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "mobile_permit_stage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "mobile_permit_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "leadAgencyId": { + "name": "leadAgencyId", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "participatingAgencyIds": { + "name": "participatingAgencyIds", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timeline": { + "name": "timeline", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permitObligations": { + "name": "permitObligations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "permitCaseId": { + "name": "permitCaseId", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "dueAt": { + "name": "dueAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "mobile_obligation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permittingAgencies": { + "name": "permittingAgencies", + "schema": "", "columns": { "id": { "name": "id", - "type": "int", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "reviewSlaHours": { + "name": "reviewSlaHours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "queueDepth": { + "name": "queueDepth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": true + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serviceTopology": { + "name": "serviceTopology", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "mobile_service_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "responsibility": { + "name": "responsibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtimeMode": { + "name": "runtimeMode", + "type": "mobile_service_runtime_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "endpointPath": { + "name": "endpointPath", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "health": { + "name": "health", + "type": "mobile_service_health", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "middlewareKeys": { + "name": "middlewareKeys", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true }, "openId": { "name": "openId", "type": "varchar(64)", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "email": { "name": "email", "type": "varchar(320)", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "loginMethod": { "name": "loginMethod", "type": "varchar(64)", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "role": { "name": "role", - "type": "enum('user','admin')", + "type": "mobile_user_role", + "typeSchema": "public", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'user'" }, "createdAt": { "name": "createdAt", - "type": "timestamp", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": "(now())" + "default": "now()" }, "updatedAt": { "name": "updatedAt", - "type": "timestamp", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" + "default": "now()" }, "lastSignedIn": { "name": "lastSignedIn", - "type": "timestamp", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": "(now())" + "default": "now()" } }, "indexes": {}, "foreignKeys": {}, - "compositePrimaryKeys": { - "users_id": { - "name": "users_id", - "columns": ["id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": { "users_openId_unique": { "name": "users_openId_unique", - "columns": ["openId"] + "nullsNotDistinct": false, + "columns": [ + "openId" + ] } }, - "checkConstraint": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, + "enums": { + "public.mobile_middleware_status": { + "name": "mobile_middleware_status", + "schema": "public", + "values": [ + "planned", + "connected", + "degraded" + ] + }, + "public.mobile_user_role": { + "name": "mobile_user_role", + "schema": "public", + "values": [ + "user", + "admin" + ] + }, + "public.mobile_obligation_status": { + "name": "mobile_obligation_status", + "schema": "public", + "values": [ + "pending", + "satisfied", + "at_risk" + ] + }, + "public.mobile_permit_priority": { + "name": "mobile_permit_priority", + "schema": "public", + "values": [ + "routine", + "elevated", + "critical" + ] + }, + "public.mobile_permit_sector": { + "name": "mobile_permit_sector", + "schema": "public", + "values": [ + "mining", + "oil_gas", + "multi_agency" + ] + }, + "public.mobile_permit_stage": { + "name": "mobile_permit_stage", + "schema": "public", + "values": [ + "intake", + "spatial_clearance", + "technical_review", + "environmental_review", + "agency_coordination", + "payment_pending", + "approval", + "issued", + "active_monitoring" + ] + }, + "public.mobile_service_health": { + "name": "mobile_service_health", + "schema": "public", + "values": [ + "healthy", + "warning" + ] + }, + "public.mobile_service_language": { + "name": "mobile_service_language", + "schema": "public", + "values": [ + "typescript", + "python", + "go", + "rust" + ] + }, + "public.mobile_service_runtime_mode": { + "name": "mobile_service_runtime_mode", + "schema": "public", + "values": [ + "webdev_backend", + "external_service", + "reserved_worker" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, "views": {}, "_meta": { + "columns": {}, "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "tables": {}, - "indexes": {} + "tables": {} } -} +} \ No newline at end of file diff --git a/mobile/drizzle/meta/0001_snapshot.json b/mobile/drizzle/meta/0001_snapshot.json deleted file mode 100644 index d622f184..00000000 --- a/mobile/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,554 +0,0 @@ -{ - "version": "5", - "dialect": "mysql", - "id": "7a0653b5-a938-4204-a5d2-2ba66157e257", - "prevId": "3c3a03ea-b871-416a-b531-aa772cca8b00", - "tables": { - "middlewareComponents": { - "name": "middlewareComponents", - "columns": { - "key": { - "name": "key", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "enum('planned','connected','degraded')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ownerService": { - "name": "ownerService", - "type": "varchar(120)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - }, - "updatedAt": { - "name": "updatedAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "middlewareComponents_key": { - "name": "middlewareComponents_key", - "columns": [ - "key" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraint": {} - }, - "permitCases": { - "name": "permitCases", - "columns": { - "id": { - "name": "id", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sector": { - "name": "sector", - "type": "enum('mining','oil_gas','multi_agency')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permitType": { - "name": "permitType", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "applicantName": { - "name": "applicantName", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "locationLabel": { - "name": "locationLabel", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assetReference": { - "name": "assetReference", - "type": "varchar(120)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "stage": { - "name": "stage", - "type": "enum('intake','spatial_clearance','technical_review','environmental_review','agency_coordination','payment_pending','approval','issued','active_monitoring')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "priority": { - "name": "priority", - "type": "enum('routine','elevated','critical')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "leadAgencyId": { - "name": "leadAgencyId", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "participatingAgencyIds": { - "name": "participatingAgencyIds", - "type": "json", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "timeline": { - "name": "timeline", - "type": "json", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - }, - "updatedAt": { - "name": "updatedAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "permitCases_id": { - "name": "permitCases_id", - "columns": [ - "id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraint": {} - }, - "permitObligations": { - "name": "permitObligations", - "columns": { - "id": { - "name": "id", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permitCaseId": { - "name": "permitCaseId", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "dueAt": { - "name": "dueAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "enum('pending','satisfied','at_risk')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owner": { - "name": "owner", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - }, - "updatedAt": { - "name": "updatedAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "permitObligations_id": { - "name": "permitObligations_id", - "columns": [ - "id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraint": {} - }, - "permittingAgencies": { - "name": "permittingAgencies", - "columns": { - "id": { - "name": "id", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "jurisdiction": { - "name": "jurisdiction", - "type": "varchar(120)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "reviewSlaHours": { - "name": "reviewSlaHours", - "type": "int", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "queueDepth": { - "name": "queueDepth", - "type": "int", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "active": { - "name": "active", - "type": "int", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - }, - "updatedAt": { - "name": "updatedAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "permittingAgencies_id": { - "name": "permittingAgencies_id", - "columns": [ - "id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraint": {} - }, - "serviceTopology": { - "name": "serviceTopology", - "columns": { - "id": { - "name": "id", - "type": "varchar(80)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "language": { - "name": "language", - "type": "enum('typescript','python','go','rust')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "responsibility": { - "name": "responsibility", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "runtimeMode": { - "name": "runtimeMode", - "type": "enum('webdev_backend','external_service','reserved_worker')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpointPath": { - "name": "endpointPath", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "health": { - "name": "health", - "type": "enum('healthy','warning')", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "middlewareKeys": { - "name": "middlewareKeys", - "type": "json", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - }, - "updatedAt": { - "name": "updatedAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "serviceTopology_id": { - "name": "serviceTopology_id", - "columns": [ - "id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraint": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "int", - "primaryKey": false, - "notNull": true, - "autoincrement": true - }, - "openId": { - "name": "openId", - "type": "varchar(64)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "varchar(320)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "loginMethod": { - "name": "loginMethod", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "enum('user','admin')", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'user'" - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - }, - "updatedAt": { - "name": "updatedAt", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "onUpdate": true, - "default": "(now())" - }, - "lastSignedIn": { - "name": "lastSignedIn", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(now())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "users_id": { - "name": "users_id", - "columns": [ - "id" - ] - } - }, - "uniqueConstraints": { - "users_openId_unique": { - "name": "users_openId_unique", - "columns": [ - "openId" - ] - } - }, - "checkConstraint": {} - } - }, - "views": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "tables": {}, - "indexes": {} - } -} \ No newline at end of file diff --git a/mobile/drizzle/meta/_journal.json b/mobile/drizzle/meta/_journal.json index 60d2e6d6..3ebe8882 100644 --- a/mobile/drizzle/meta/_journal.json +++ b/mobile/drizzle/meta/_journal.json @@ -1,19 +1,12 @@ { "version": "7", - "dialect": "mysql", + "dialect": "postgresql", "entries": [ { "idx": 0, - "version": "5", - "when": 1763372440610, - "tag": "0000_elite_eternals", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1784555093806, - "tag": "0001_brown_nico_minoru", + "version": "7", + "when": 1788309216022, + "tag": "0000_postgresql_baseline", "breakpoints": true } ] diff --git a/mobile/drizzle/schema.ts b/mobile/drizzle/schema.ts index 28d0ed33..3bfb9b39 100644 --- a/mobile/drizzle/schema.ts +++ b/mobile/drizzle/schema.ts @@ -1,93 +1,106 @@ -import { int, json, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core"; +import { boolean, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core"; + +export const mobileUserRole = pgEnum("mobile_user_role", ["user", "admin"]); +export const permitSector = pgEnum("mobile_permit_sector", ["mining", "oil_gas", "multi_agency"]); +export const permitStage = pgEnum("mobile_permit_stage", [ + "intake", + "spatial_clearance", + "technical_review", + "environmental_review", + "agency_coordination", + "payment_pending", + "approval", + "issued", + "active_monitoring", +]); +export const permitPriority = pgEnum("mobile_permit_priority", ["routine", "elevated", "critical"]); +export const obligationStatus = pgEnum("mobile_obligation_status", ["pending", "satisfied", "at_risk"]); +export const middlewareStatus = pgEnum("mobile_middleware_status", ["planned", "connected", "degraded"]); +export const serviceLanguage = pgEnum("mobile_service_language", ["typescript", "python", "go", "rust"]); +export const serviceRuntimeMode = pgEnum("mobile_service_runtime_mode", ["webdev_backend", "external_service", "reserved_worker"]); +export const serviceHealth = pgEnum("mobile_service_health", ["healthy", "warning"]); + +const createdAt = timestamp("createdAt", { withTimezone: true }).defaultNow().notNull(); +const updatedAt = timestamp("updatedAt", { withTimezone: true }).defaultNow().$onUpdate(() => new Date()).notNull(); /** - * Core user table backing auth flow. - * Columns use camelCase to match both database fields and generated types. + * Core user table backing the mobile and portable authentication flow. + * PostgreSQL is the only supported relational persistence engine. */ -export const users = mysqlTable("users", { - id: int("id").autoincrement().primaryKey(), +export const users = pgTable("users", { + id: serial("id").primaryKey(), openId: varchar("openId", { length: 64 }).notNull().unique(), name: text("name"), email: varchar("email", { length: 320 }), loginMethod: varchar("loginMethod", { length: 64 }), - role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), - lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), + role: mobileUserRole("role").default("user").notNull(), + createdAt, + updatedAt, + lastSignedIn: timestamp("lastSignedIn", { withTimezone: true }).defaultNow().notNull(), }); -export const permittingAgencies = mysqlTable("permittingAgencies", { +export const permittingAgencies = pgTable("permittingAgencies", { id: varchar("id", { length: 80 }).primaryKey(), name: varchar("name", { length: 255 }).notNull(), role: text("role").notNull(), jurisdiction: varchar("jurisdiction", { length: 120 }).notNull(), - reviewSlaHours: int("reviewSlaHours").notNull(), - queueDepth: int("queueDepth").notNull(), - active: int("active").default(1).notNull(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + reviewSlaHours: integer("reviewSlaHours").notNull(), + queueDepth: integer("queueDepth").notNull(), + active: boolean("active").default(true).notNull(), + createdAt, + updatedAt, }); -export const permitCases = mysqlTable("permitCases", { +export const permitCases = pgTable("permitCases", { id: varchar("id", { length: 80 }).primaryKey(), - sector: mysqlEnum("sector", ["mining", "oil_gas", "multi_agency"]).notNull(), + sector: permitSector("sector").notNull(), permitType: varchar("permitType", { length: 255 }).notNull(), title: varchar("title", { length: 255 }).notNull(), applicantName: varchar("applicantName", { length: 255 }).notNull(), locationLabel: varchar("locationLabel", { length: 255 }).notNull(), assetReference: varchar("assetReference", { length: 120 }).notNull(), - stage: mysqlEnum("stage", [ - "intake", - "spatial_clearance", - "technical_review", - "environmental_review", - "agency_coordination", - "payment_pending", - "approval", - "issued", - "active_monitoring", - ]).notNull(), - priority: mysqlEnum("priority", ["routine", "elevated", "critical"]).notNull(), + stage: permitStage("stage").notNull(), + priority: permitPriority("priority").notNull(), leadAgencyId: varchar("leadAgencyId", { length: 80 }).notNull(), - participatingAgencyIds: json("participatingAgencyIds").$type().notNull(), + participatingAgencyIds: jsonb("participatingAgencyIds").$type().notNull(), summary: text("summary").notNull(), - timeline: json("timeline").$type>>().notNull(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + timeline: jsonb("timeline").$type>>().notNull(), + createdAt, + updatedAt, }); -export const permitObligations = mysqlTable("permitObligations", { +export const permitObligations = pgTable("permitObligations", { id: varchar("id", { length: 80 }).primaryKey(), permitCaseId: varchar("permitCaseId", { length: 80 }).notNull(), title: varchar("title", { length: 255 }).notNull(), - dueAt: timestamp("dueAt").notNull(), - status: mysqlEnum("status", ["pending", "satisfied", "at_risk"]).notNull(), + dueAt: timestamp("dueAt", { withTimezone: true }).notNull(), + status: obligationStatus("status").notNull(), owner: varchar("owner", { length: 255 }).notNull(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + createdAt, + updatedAt, }); -export const middlewareComponents = mysqlTable("middlewareComponents", { +export const middlewareComponents = pgTable("middlewareComponents", { key: varchar("key", { length: 80 }).primaryKey(), name: varchar("name", { length: 255 }).notNull(), purpose: text("purpose").notNull(), - status: mysqlEnum("status", ["planned", "connected", "degraded"]).notNull(), + status: middlewareStatus("status").notNull(), ownerService: varchar("ownerService", { length: 120 }).notNull(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + createdAt, + updatedAt, }); -export const serviceTopology = mysqlTable("serviceTopology", { +export const serviceTopology = pgTable("serviceTopology", { id: varchar("id", { length: 80 }).primaryKey(), name: varchar("name", { length: 255 }).notNull(), - language: mysqlEnum("language", ["typescript", "python", "go", "rust"]).notNull(), + language: serviceLanguage("language").notNull(), responsibility: text("responsibility").notNull(), - runtimeMode: mysqlEnum("runtimeMode", ["webdev_backend", "external_service", "reserved_worker"]).notNull(), + runtimeMode: serviceRuntimeMode("runtimeMode").notNull(), endpointPath: varchar("endpointPath", { length: 255 }).notNull(), - health: mysqlEnum("health", ["healthy", "warning"]).notNull(), - middlewareKeys: json("middlewareKeys").$type().notNull(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + health: serviceHealth("health").notNull(), + middlewareKeys: jsonb("middlewareKeys").$type().notNull(), + createdAt, + updatedAt, }); export type User = typeof users.$inferSelect; diff --git a/mobile/package.json b/mobile/package.json index e9d52b6c..b4ae9767 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -78,7 +78,6 @@ "express": "^4.22.1", "jose": "6.1.0", "leaflet": "^1.9.4", - "mysql2": "^3.16.0", "nativewind": "^4.2.1", "pdf-parse": "^2.4.5", "pg": "^8.23.0", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index b259e474..368dbe24 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -5,6 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: + browserslist: 4.28.7 + mysql2: 3.22.0 micromatch>picomatch: 4.0.4 '@babel/core@<=7.29.0': '>=7.29.6' '@trpc/server@>=11.0.0 <11.8.0': '>=11.8.0' @@ -147,7 +149,7 @@ importers: version: 16.6.1 drizzle-orm: specifier: '>=0.45.2' - version: 0.45.2(@types/pg@8.21.0)(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(mysql2@3.16.0)(pg@8.23.0) + version: 0.45.2(@types/pg@8.21.0)(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(pg@8.23.0) expo: specifier: ~57.0.18 version: 57.0.18(@babel/core@8.0.1)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(expo-router@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -247,9 +249,6 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 - mysql2: - specifier: ^3.16.0 - version: 3.16.0 nativewind: specifier: ^4.2.1 version: 4.2.1(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) @@ -2714,10 +2713,6 @@ packages: await-lock@2.2.2: resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} - aws-ssl-profiles@1.1.2: - resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} - engines: {node: '>= 6.0.0'} - axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} @@ -2779,8 +2774,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.8: - resolution: {integrity: sha512-Y1fOuNDowLfgKOypdc9SPABfoWXuZHBOyCS4cD52IeZBhr4Md6CLLs6atcxVrzRmQ06E7hSlm5bHHApPKR/byA==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} hasBin: true big-integer@1.6.52: @@ -2817,8 +2813,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2868,8 +2864,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001760: - resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -3140,10 +3136,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3227,7 +3219,7 @@ packages: gel: '>=2' knex: '*' kysely: '*' - mysql2: '>=2' + mysql2: 3.22.0 pg: '>=8' postgres: '>=3' prisma: '*' @@ -3300,8 +3292,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.420: + resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3935,9 +3927,6 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - generate-function@2.3.1: - resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -4103,10 +4092,6 @@ packages: hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} - iconv-lite@0.7.1: - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -4234,9 +4219,6 @@ packages: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} - is-property@1.0.2: - resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4555,9 +4537,6 @@ packages: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -4576,10 +4555,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru.min@1.1.3: - resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} - engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4751,17 +4726,9 @@ packages: multitars@1.0.2: resolution: {integrity: sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==} - mysql2@3.16.0: - resolution: {integrity: sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==} - engines: {node: '>= 8.0'} - mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - named-placeholders@1.1.6: - resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} - engines: {node: '>=8.0.0'} - nanoid@6.0.1: resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} engines: {node: ^22 || ^24 || >=26} @@ -4809,8 +4776,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -5506,9 +5474,6 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} @@ -5618,10 +5583,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} - stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -5916,7 +5877,7 @@ packages: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: 4.28.7 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6300,7 +6261,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.5 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -6308,7 +6269,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.1 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -6316,7 +6277,7 @@ snapshots: dependencies: '@babel/compat-data': 8.0.0 '@babel/helper-validator-option': 8.0.0 - browserslist: 4.28.1 + browserslist: 4.28.7 lru-cache: 11.2.4 semver: 7.7.3 @@ -7366,7 +7327,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - browserslist: 4.28.1 + browserslist: 4.28.7 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 @@ -8720,8 +8681,6 @@ snapshots: await-lock@2.2.2: {} - aws-ssl-profiles@1.1.2: {} - axios@1.20.0: dependencies: follow-redirects: 1.16.0 @@ -8840,7 +8799,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.9.8: {} + baseline-browser-mapping@2.11.20: {} big-integer@1.6.52: {} @@ -8882,13 +8841,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.9.8 - caniuse-lite: 1.0.30001760 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.420 + node-releases: 2.0.54 + update-browserslist-db: 1.2.3(browserslist@4.28.7) bser@2.1.1: dependencies: @@ -8935,7 +8894,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001760: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -9109,7 +9068,7 @@ snapshots: core-js-compat@3.47.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.28.7 core-util-is@1.0.3: {} @@ -9216,8 +9175,6 @@ snapshots: delayed-stream@1.0.0: {} - denque@2.1.0: {} - depd@2.0.0: {} destroy@1.2.0: {} @@ -9269,11 +9226,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.2(@types/pg@8.21.0)(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(mysql2@3.16.0)(pg@8.23.0): + drizzle-orm@0.45.2(@types/pg@8.21.0)(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(pg@8.23.0): optionalDependencies: '@types/pg': 8.21.0 expo-sqlite: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@8.0.1)(@react-native/metro-config@0.86.3(@babel/core@8.0.1))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) - mysql2: 3.16.0 pg: 8.23.0 dunder-proto@1.0.1: @@ -9284,7 +9240,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.420: {} emoji-regex@8.0.0: {} @@ -10191,10 +10147,6 @@ snapshots: functions-have-names@1.2.3: {} - generate-function@2.3.1: - dependencies: - is-property: 1.0.2 - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -10371,10 +10323,6 @@ snapshots: hyphenate-style-name@1.1.0: {} - iconv-lite@0.7.1: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -10495,8 +10443,6 @@ snapshots: is-plain-obj@2.1.0: {} - is-property@1.0.2: {} - is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10761,8 +10707,6 @@ snapshots: dependencies: chalk: 2.4.2 - long@5.3.2: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -10777,8 +10721,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru.min@1.1.3: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -11028,28 +10970,12 @@ snapshots: multitars@1.0.2: {} - mysql2@3.16.0: - dependencies: - aws-ssl-profiles: 1.1.2 - denque: 2.1.0 - generate-function: 2.3.1 - iconv-lite: 0.7.1 - long: 5.3.2 - lru.min: 1.1.3 - named-placeholders: 1.1.6 - seq-queue: 0.0.5 - sqlstring: 2.3.3 - mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 - named-placeholders@1.1.6: - dependencies: - lru.min: 1.1.3 - nanoid@6.0.1: {} napi-postinstall@0.3.4: {} @@ -11086,7 +11012,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.27: {} + node-releases@2.0.54: {} normalize-path@3.0.0: {} @@ -11867,8 +11793,6 @@ snapshots: transitivePeerDependencies: - supports-color - seq-queue@0.0.5: {} - serialize-error@2.1.0: {} serve-static@1.16.3: @@ -11983,8 +11907,6 @@ snapshots: split2@4.2.0: {} - sqlstring@2.3.3: {} - stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -12326,9 +12248,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml index dce4bec6..10fe822e 100644 --- a/mobile/pnpm-workspace.yaml +++ b/mobile/pnpm-workspace.yaml @@ -1,6 +1,8 @@ packages: - '.' overrides: + "browserslist": "4.28.7" + "mysql2": "3.22.0" "micromatch>picomatch": "4.0.4" "@babel/core@<=7.29.0": ">=7.29.6" "@trpc/server@>=11.0.0 <11.8.0": ">=11.8.0" diff --git a/mobile/server/db.ts b/mobile/server/db.ts index 37076d6c..bb32e8e1 100644 --- a/mobile/server/db.ts +++ b/mobile/server/db.ts @@ -1,92 +1,79 @@ import { eq } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/mysql2"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; import { InsertUser, users } from "../drizzle/schema"; import { ENV } from "./_core/env"; -let _db: ReturnType | null = null; +let pool: Pool | null = null; +let db: NodePgDatabase | null = null; -// Lazily create the drizzle instance so local tooling can run without a DB. -export async function getDb() { - if (!_db && process.env.DATABASE_URL) { - try { - _db = drizzle(process.env.DATABASE_URL); - } catch (error) { - console.warn("[Database] Failed to connect:", error); - _db = null; - } +function databaseUrl() { + const value = process.env.DATABASE_URL; + if (!value) throw new Error("DATABASE_URL is required for mobile persistence"); + if (!/^postgres(?:ql)?:\/\//i.test(value)) { + throw new Error("mobile persistence requires a PostgreSQL DATABASE_URL"); } - return _db; + return value; } -export async function upsertUser(user: InsertUser): Promise { - if (!user.openId) { - throw new Error("User openId is required for upsert"); - } - - const db = await getDb(); +/** + * Lazily creates the PostgreSQL-backed Drizzle adapter. Importing portable + * tooling remains possible without a database, but every persistence operation + * requires an explicit PostgreSQL connection string and fails closed otherwise. + */ +export async function getDb(): Promise { if (!db) { - console.warn("[Database] Cannot upsert user: database not available"); - return; + pool = new Pool({ connectionString: databaseUrl() }); + db = drizzle(pool); } + return db; +} - try { - const values: InsertUser = { - openId: user.openId, - }; - const updateSet: Record = {}; - - const textFields = ["name", "email", "loginMethod"] as const; - type TextField = (typeof textFields)[number]; - - const assignNullable = (field: TextField) => { - const value = user[field]; - if (value === undefined) return; - const normalized = value ?? null; - values[field] = normalized; - updateSet[field] = normalized; - }; - - textFields.forEach(assignNullable); - - if (user.lastSignedIn !== undefined) { - values.lastSignedIn = user.lastSignedIn; - updateSet.lastSignedIn = user.lastSignedIn; - } - if (user.role !== undefined) { - values.role = user.role; - updateSet.role = user.role; - } else if (user.openId === ENV.ownerOpenId) { - values.role = "admin"; - updateSet.role = "admin"; - } - - if (!values.lastSignedIn) { - values.lastSignedIn = new Date(); - } +export async function closeDbForTests() { + const activePool = pool; + pool = null; + db = null; + await activePool?.end(); +} - if (Object.keys(updateSet).length === 0) { - updateSet.lastSignedIn = new Date(); - } +export async function upsertUser(user: InsertUser): Promise { + if (!user.openId) throw new Error("User openId is required for upsert"); + + const values: InsertUser = { openId: user.openId }; + const updateSet: Record = {}; + const textFields = ["name", "email", "loginMethod"] as const; + type TextField = (typeof textFields)[number]; + + for (const field of textFields) { + const value = user[field]; + if (value === undefined) continue; + const normalized = value ?? null; + values[field] = normalized; + updateSet[field] = normalized; + } - await db.insert(users).values(values).onDuplicateKeyUpdate({ - set: updateSet, - }); - } catch (error) { - console.error("[Database] Failed to upsert user:", error); - throw error; + if (user.lastSignedIn !== undefined) { + values.lastSignedIn = user.lastSignedIn; + updateSet.lastSignedIn = user.lastSignedIn; } + if (user.role !== undefined) { + values.role = user.role; + updateSet.role = user.role; + } else if (user.openId === ENV.ownerOpenId) { + values.role = "admin"; + updateSet.role = "admin"; + } + if (!values.lastSignedIn) values.lastSignedIn = new Date(); + if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date(); + updateSet.updatedAt = new Date(); + + await (await getDb()).insert(users).values(values).onConflictDoUpdate({ + target: users.openId, + set: updateSet, + }); } export async function getUserByOpenId(openId: string) { - const db = await getDb(); - if (!db) { - console.warn("[Database] Cannot get user: database not available"); - return undefined; - } - - const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1); - + const result = await (await getDb()).select().from(users).where(eq(users.openId, openId)).limit(1); return result.length > 0 ? result[0] : undefined; } - -// TODO: add feature queries here as your schema grows. diff --git a/mobile/tests/postgresql-db-adapter.integration.test.ts b/mobile/tests/postgresql-db-adapter.integration.test.ts new file mode 100644 index 00000000..1e171c22 --- /dev/null +++ b/mobile/tests/postgresql-db-adapter.integration.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { Pool } from "pg"; + +import { closeDbForTests, getUserByOpenId, upsertUser } from "../server/db"; + +const testDatabaseUrl = process.env.MOBILE_POSTGRES_TEST_DATABASE_URL; +const isDedicatedLocalTestUrl = (url: string | undefined) => { + if (!url) return false; + const authority = url.split("@").at(-1)?.split("/")[0] ?? ""; + return /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(authority) && /\/idlr_payment_test(?:\?|$)/.test(url); +}; + +if (!isDedicatedLocalTestUrl(testDatabaseUrl)) { + throw new Error("MOBILE_POSTGRES_TEST_DATABASE_URL must target the dedicated local idlr_payment_test database"); +} + +describe("mobile PostgreSQL Drizzle adapter", () => { + const originalDatabaseUrl = process.env.DATABASE_URL; + const pool = new Pool({ connectionString: testDatabaseUrl }); + + beforeEach(async () => { + process.env.DATABASE_URL = testDatabaseUrl; + await closeDbForTests(); + await pool.query('DELETE FROM "users" WHERE "openId" = $1', ["mobile-postgres-integration-user"]); + }); + + afterAll(async () => { + await closeDbForTests(); + if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalDatabaseUrl; + await pool.end(); + }); + + it("persists and updates a user through PostgreSQL with a database-maintained timestamp", async () => { + await upsertUser({ + openId: "mobile-postgres-integration-user", + name: "First Name", + email: "first@example.test", + loginMethod: "oidc", + role: "user", + lastSignedIn: new Date("2026-09-02T00:00:00.000Z"), + }); + + const first = await pool.query<{ updatedAt: Date }>('SELECT "updatedAt" FROM "users" WHERE "openId" = $1', ["mobile-postgres-integration-user"]); + expect(first.rowCount).toBe(1); + + await new Promise((resolve) => setTimeout(resolve, 5)); + await upsertUser({ + openId: "mobile-postgres-integration-user", + name: "Second Name", + email: "second@example.test", + loginMethod: "oidc", + role: "admin", + lastSignedIn: new Date("2026-09-02T00:01:00.000Z"), + }); + + const user = await getUserByOpenId("mobile-postgres-integration-user"); + const second = await pool.query<{ updatedAt: Date }>('SELECT "updatedAt" FROM "users" WHERE "openId" = $1', ["mobile-postgres-integration-user"]); + expect(user).toMatchObject({ + openId: "mobile-postgres-integration-user", + name: "Second Name", + email: "second@example.test", + role: "admin", + }); + expect(second.rows[0].updatedAt.getTime()).toBeGreaterThanOrEqual(first.rows[0].updatedAt.getTime()); + }); + + it("rejects a non-PostgreSQL database URL before creating a persistence adapter", async () => { + await closeDbForTests(); + process.env.DATABASE_URL = "mysql://invalid:invalid@127.0.0.1:3306/retired"; + await expect(getUserByOpenId("mobile-postgres-integration-user")).rejects.toThrow( + "mobile persistence requires a PostgreSQL DATABASE_URL", + ); + }); +}); diff --git a/orchestration/docker-compose.ml-candidate.yml b/orchestration/docker-compose.ml-candidate.yml new file mode 100644 index 00000000..b7053ab9 --- /dev/null +++ b/orchestration/docker-compose.ml-candidate.yml @@ -0,0 +1,40 @@ +services: + ndsep-ml-foundation: + build: + context: ../workers/python + dockerfile: Dockerfile.ml-foundation + environment: + NDSEP_ML_MODEL_DIR: /var/lib/ndsep-ml/models + NDSEP_ML_PORT: "8251" + PYTHONUNBUFFERED: "1" + ports: + - "127.0.0.1:8251:8251" + volumes: + - type: bind + source: ${NDSEP_SYNTHETIC_LAKEHOUSE_DIR:?set a synthetic-only Lakehouse directory} + target: /var/lib/ndsep-ml/lakehouse + read_only: true + - type: bind + source: ${NDSEP_CANDIDATE_MODEL_DIR:?set a signed candidate model artifact directory} + target: /var/lib/ndsep-ml/models + read_only: true + read_only: true + tmpfs: + - /tmp:size=64m,noexec,nosuid,nodev + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + user: "10001:10001" + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8251/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + networks: + - ml_candidate + +networks: + ml_candidate: + internal: true diff --git a/orchestration/docker-compose.yml b/orchestration/docker-compose.yml index faa095f7..6e1a45f4 100644 --- a/orchestration/docker-compose.yml +++ b/orchestration/docker-compose.yml @@ -72,14 +72,19 @@ services: keycloak: image: quay.io/keycloak/keycloak:23.0 - command: start-dev --import-realm + command: start --import-realm environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: ndsep_admin_2026 + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?KEYCLOAK_ADMIN_USER is required} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?KEYCLOAK_ADMIN_PASSWORD is required} KC_DB: postgres - KC_DB_URL: jdbc:postgresql://postgres:5432/ndsep - KC_DB_USERNAME: ndsep - KC_DB_PASSWORD: ndsep_secure_2026 + KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required} + KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME:?KEYCLOAK_DB_USERNAME is required} + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?KEYCLOAK_DB_PASSWORD is required} + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:?KEYCLOAK_HOSTNAME is required} + KC_HOSTNAME_STRICT: "true" + KC_PROXY_HEADERS: xforwarded + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" ports: ["8080:8080"] depends_on: [postgres] networks: [ndsep-net] @@ -234,6 +239,19 @@ services: depends_on: [kafka] networks: [ndsep-net] + lakehouse-ingest: + build: + context: ../workers/rust + dockerfile: Dockerfile + args: {WORKER: lakehouse_ingest} + ports: ["8163:8163"] + environment: + LAKEHOUSE_INGEST_PORT: "8163" + LAKEHOUSE_DATABASE_URL: ${LAKEHOUSE_INGEST_DATABASE_URL:?LAKEHOUSE_INGEST_DATABASE_URL is required} + LAKEHOUSE_URL: ${LAKEHOUSE_INGEST_URL:?LAKEHOUSE_INGEST_URL is required} + depends_on: [postgres, lakehouse] + networks: [ndsep-net] + dapr-bindings: build: context: ./python/dapr_bindings @@ -354,6 +372,8 @@ services: ports: ["8330:8330"] environment: PORT: "8330" + DPCO_ANALYTICS_DATABASE_URL: ${DPCO_ANALYTICS_DATABASE_URL:?DPCO_ANALYTICS_DATABASE_URL is required} + DPCO_ANALYTICS_SERVICE_TOKEN: ${DPCO_ANALYTICS_SERVICE_TOKEN:?DPCO_ANALYTICS_SERVICE_TOKEN is required} KAFKA_BROKERS: kafka:29092 KAFKA_ENABLED: "true" FLUVIO_HTTP_URL: http://fluvio:9003 @@ -367,7 +387,7 @@ services: DAPR_HTTP_PORT: "3500" DAPR_APP_ID: dpco-analytics DAPR_ENABLED: "true" - depends_on: [kafka, redis] + depends_on: [postgres, kafka, redis] networks: [ndsep-net] restart: unless-stopped healthcheck: diff --git a/orchestration/go/cmd/api_gateway/main.go b/orchestration/go/cmd/api_gateway/main.go index 4eac7197..cfd25481 100644 --- a/orchestration/go/cmd/api_gateway/main.go +++ b/orchestration/go/cmd/api_gateway/main.go @@ -14,6 +14,7 @@ import ( "log" "net/http" "os" + "strings" "sync" "sync/atomic" "time" @@ -33,7 +34,7 @@ func getenv(key, fallback string) string { var ( apisixAdminURL = getenv("APISIX_ADMIN_URL", "http://localhost:9180") - apisixAdminKey = getenv("APISIX_ADMIN_KEY", "edd1c9f034335f136f87ad84b625c8f1") + apisixAdminKey = os.Getenv("APISIX_ADMIN_KEY") apisixEnabled = getenv("APISIX_ENABLED", "true") == "true" ) @@ -225,6 +226,14 @@ func metricsHandler(w http.ResponseWriter, _ *http.Request) { } func main() { + if apisixEnabled { + if len(apisixAdminKey) < 32 { + logger.Fatal("APISIX_ADMIN_KEY must be a high-entropy secret of at least 32 characters when APISIX is enabled") + } + if os.Getenv("NODE_ENV") == "production" && !strings.HasPrefix(apisixAdminURL, "https://") { + logger.Fatal("APISIX_ADMIN_URL must use https:// in production") + } + } port := getenv("PORT", "8130") initApisix() diff --git a/orchestration/go/cmd/dpco_registry_service/main.go b/orchestration/go/cmd/dpco_registry_service/main.go index 9ce74af5..f963cf2e 100644 --- a/orchestration/go/cmd/dpco_registry_service/main.go +++ b/orchestration/go/cmd/dpco_registry_service/main.go @@ -248,24 +248,29 @@ func initTigerBeetle() { }() } -func recordFeeEntry(dpcoID, feeType string, amountNGN float64) (string, error) { +func recordFeeEntry(dpcoID, feeType string, amountNGN json.Number) (string, error) { mu.RLock() ok := tigerbeetleOK mu.RUnlock() if !ok { return "", fmt.Errorf("TigerBeetle ledger is unavailable for %s fee", feeType) } + if strings.TrimSpace(amountNGN.String()) == "" { + return "", fmt.Errorf("NGN fee amount is required") + } body := map[string]interface{}{ - "org_id": dpcoID, - "violation_id": fmt.Sprintf("dpco-%s-%s", feeType, dpcoID), - "amount_usd": amountNGN / 1500.0, - "currency": "NGN", + "org_id": dpcoID, + "penalty_id": fmt.Sprintf("dpco:%s:%s", feeType, dpcoID), + "amount": amountNGN, + "currency": "NGN", + "type": "fine", + "description": fmt.Sprintf("DPCO %s fee", feeType), } b, err := json.Marshal(body) if err != nil { return "", fmt.Errorf("encode TigerBeetle fee entry: %w", err) } - resp, err := http.Post(fmt.Sprintf("%s/penalty/issue", tigerbeetleURL), "application/json", bytes.NewReader(b)) + resp, err := http.Post(fmt.Sprintf("%s/transaction", tigerbeetleURL), "application/json", bytes.NewReader(b)) if err != nil { return "", fmt.Errorf("post TigerBeetle fee entry: %w", err) } @@ -397,16 +402,18 @@ func requireLifecycleString(record map[string]interface{}, key string) (string, func registerDpco(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` - LicenceNumber string `json:"licence_number"` - Type string `json:"type"` - State string `json:"state"` - Email string `json:"email"` - Phone string `json:"phone"` - Address string `json:"address"` - FeeNGN float64 `json:"fee_ngn"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Name string `json:"name"` + LicenceNumber string `json:"licence_number"` + Type string `json:"type"` + State string `json:"state"` + Email string `json:"email"` + Phone string `json:"phone"` + Address string `json:"address"` + FeeNGN json.Number `json:"fee_ngn"` + } + decoder := json.NewDecoder(r.Body) + decoder.UseNumber() + if err := decoder.Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } @@ -414,8 +421,9 @@ func registerDpco(w http.ResponseWriter, r *http.Request) { if req.LicenceNumber == "" { req.LicenceNumber = fmt.Sprintf("NDPC/DPCO/%04d/%d", time.Now().Year(), time.Now().UnixNano()%10000) } - if req.FeeNGN == 0 { - req.FeeNGN = 500000 // ₦500,000 default DPCO registration fee + if strings.TrimSpace(req.FeeNGN.String()) == "" { + http.Error(w, `{"error":"fee_ngn is required before durable registration"}`, http.StatusBadRequest) + return } dpco := map[string]interface{}{ "id": id, "name": req.Name, "licence_number": req.LicenceNumber, @@ -473,11 +481,17 @@ func renewLicence(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var req struct { - FeeNGN float64 `json:"fee_ngn"` + FeeNGN json.Number `json:"fee_ngn"` } - json.NewDecoder(r.Body).Decode(&req) - if req.FeeNGN == 0 { - req.FeeNGN = 500000 + decoder := json.NewDecoder(r.Body) + decoder.UseNumber() + if err := decoder.Decode(&req); err != nil { + http.Error(w, `{"error":"invalid renewal request"}`, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.FeeNGN.String()) == "" { + http.Error(w, `{"error":"fee_ngn is required before durable renewal"}`, http.StatusBadRequest) + return } dpco, err := loadRegistryRecord(r.Context(), id) if err != nil { diff --git a/orchestration/go/cmd/dpco_registry_service/tigerbeetle_fee_integration_test.go b/orchestration/go/cmd/dpco_registry_service/tigerbeetle_fee_integration_test.go index 49f4d4e6..2d61e9cc 100644 --- a/orchestration/go/cmd/dpco_registry_service/tigerbeetle_fee_integration_test.go +++ b/orchestration/go/cmd/dpco_registry_service/tigerbeetle_fee_integration_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "net/http" "net/http/httptest" "sync/atomic" @@ -25,7 +26,7 @@ func withTigerBeetleTestState(t *testing.T, url string, available bool) { func TestRecordFeeEntryRejectsUnavailableLedger(t *testing.T) { withTigerBeetleTestState(t, "http://127.0.0.1:1", false) - transactionID, err := recordFeeEntry("123", "registration", 1000) + transactionID, err := recordFeeEntry("123", "registration", json.Number("1000.00")) if err == nil { t.Fatal("expected unavailable TigerBeetle ledger to return an error") } @@ -36,7 +37,7 @@ func TestRecordFeeEntryRejectsUnavailableLedger(t *testing.T) { func TestRecordFeeEntryRejectsNetworkFailure(t *testing.T) { withTigerBeetleTestState(t, "http://127.0.0.1:1", true) - transactionID, err := recordFeeEntry("123", "registration", 1000) + transactionID, err := recordFeeEntry("123", "registration", json.Number("1000.00")) if err == nil { t.Fatal("expected unreachable TigerBeetle endpoint to return an error") } @@ -48,14 +49,14 @@ func TestRecordFeeEntryRejectsNetworkFailure(t *testing.T) { func TestRecordFeeEntryRejectsUpstreamRejectionAndMalformedSuccess(t *testing.T) { t.Run("rejected status", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/penalty/issue" { + if r.URL.Path != "/transaction" { t.Fatalf("unexpected endpoint %q", r.URL.Path) } http.Error(w, `{"error":"ledger rejected transfer"}`, http.StatusBadGateway) })) defer server.Close() withTigerBeetleTestState(t, server.URL, true) - transactionID, err := recordFeeEntry("123", "renewal", 1000) + transactionID, err := recordFeeEntry("123", "renewal", json.Number("1000.00")) if err == nil || transactionID != "" { t.Fatalf("expected rejected ledger request to fail without ID; id=%q err=%v", transactionID, err) } @@ -68,7 +69,7 @@ func TestRecordFeeEntryRejectsUpstreamRejectionAndMalformedSuccess(t *testing.T) })) defer server.Close() withTigerBeetleTestState(t, server.URL, true) - transactionID, err := recordFeeEntry("123", "renewal", 1000) + transactionID, err := recordFeeEntry("123", "renewal", json.Number("1000.00")) if err == nil || transactionID != "" { t.Fatalf("expected missing transaction ID to fail; id=%q err=%v", transactionID, err) } @@ -77,13 +78,26 @@ func TestRecordFeeEntryRejectsUpstreamRejectionAndMalformedSuccess(t *testing.T) func TestRecordFeeEntryAcceptsOnlyDurableTransactionID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/transaction" { + t.Fatalf("unexpected endpoint %q", r.URL.Path) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body["org_id"] != "123" || body["penalty_id"] != "dpco:registration:123" || body["currency"] != "NGN" || body["type"] != "fine" { + t.Fatalf("unexpected durable ledger request: %#v", body) + } + if body["amount"] != float64(1000) { + t.Fatalf("expected exact NGN amount, got %#v", body["amount"]) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"transaction_id":"tb-registry-123"}`)) })) defer server.Close() withTigerBeetleTestState(t, server.URL, true) before := atomic.LoadInt64(&tbEntries) - transactionID, err := recordFeeEntry("123", "registration", 1000) + transactionID, err := recordFeeEntry("123", "registration", json.Number("1000.00")) if err != nil { t.Fatalf("expected durable TigerBeetle response to succeed: %v", err) } diff --git a/orchestration/go/cmd/iam_service/main.go b/orchestration/go/cmd/iam_service/main.go index 4ac3372b..4df1454a 100644 --- a/orchestration/go/cmd/iam_service/main.go +++ b/orchestration/go/cmd/iam_service/main.go @@ -39,12 +39,12 @@ func getenv(key, fallback string) string { } var ( - keycloakURL = getenv("KEYCLOAK_URL", "http://localhost:8080") - keycloakRealm = getenv("KEYCLOAK_REALM", "ndsep") - keycloakClientID = getenv("KEYCLOAK_CLIENT_ID", "ndsep-platform") - keycloakSecret = getenv("KEYCLOAK_CLIENT_SECRET", "") - permifyURL = getenv("PERMIFY_URL", "http://localhost:3476") - permifyTenant = getenv("PERMIFY_TENANT", "ndsep") + keycloakURL = strings.TrimRight(strings.TrimSpace(os.Getenv("KEYCLOAK_URL")), "/") + keycloakRealm = strings.TrimSpace(os.Getenv("KEYCLOAK_REALM")) + keycloakClientID = strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_ID")) + keycloakSecret = strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")) + permifyURL = strings.TrimRight(strings.TrimSpace(os.Getenv("PERMIFY_URL")), "/") + permifyTenant = strings.TrimSpace(os.Getenv("PERMIFY_TENANT")) keycloakEnabled = getenv("KEYCLOAK_ENABLED", "true") == "true" permifyEnabled = getenv("PERMIFY_ENABLED", "true") == "true" ) @@ -59,6 +59,24 @@ var ( permChecks int64 ) +func validateIAMConfiguration() error { + if keycloakEnabled { + if keycloakURL == "" || keycloakRealm == "" || keycloakClientID == "" || keycloakSecret == "" { + return fmt.Errorf("KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, and KEYCLOAK_CLIENT_SECRET are required when Keycloak is enabled") + } + if os.Getenv("NODE_ENV") == "production" && !strings.HasPrefix(keycloakURL, "https://") { + return fmt.Errorf("KEYCLOAK_URL must use https:// in production") + } + } + if permifyEnabled && (permifyURL == "" || permifyTenant == "") { + return fmt.Errorf("PERMIFY_URL and PERMIFY_TENANT are required when Permify is enabled") + } + if os.Getenv("NODE_ENV") == "production" && (!keycloakEnabled || !permifyEnabled) { + return fmt.Errorf("Keycloak and Permify must remain enabled in production") + } + return nil +} + // ─── Keycloak Init ──────────────────────────────────────────────────────────── func initKeycloak() { @@ -99,7 +117,13 @@ func initPermify() { } go func() { for { - resp, err := http.Get(permifyURL + "/healthz") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, permifyURL+"/healthz", nil) + var resp *http.Response + if err == nil { + resp, err = (&http.Client{Timeout: 5 * time.Second}).Do(req) + } + cancel() mu.Lock() if err == nil && resp.StatusCode == 200 { if !permifyOK { @@ -209,8 +233,8 @@ func healthHandler(w http.ResponseWriter, _ *http.Request) { pOK := permifyOK mu.RUnlock() status := "healthy" - if !kOK { - status = "degraded" + if !kOK || !pOK { + status = "unavailable" } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -292,6 +316,9 @@ func metricsHandler(w http.ResponseWriter, _ *http.Request) { } func main() { + if err := validateIAMConfiguration(); err != nil { + logger.Fatal(err) + } port := getenv("PORT", "8150") initKeycloak() initPermify() diff --git a/orchestration/go/cmd/iam_service/main_test.go b/orchestration/go/cmd/iam_service/main_test.go new file mode 100644 index 00000000..223ab077 --- /dev/null +++ b/orchestration/go/cmd/iam_service/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "testing" +) + +func TestValidateIAMConfigurationRequiresEnabledDependencies(t *testing.T) { + originalURL, originalRealm, originalClientID, originalSecret := keycloakURL, keycloakRealm, keycloakClientID, keycloakSecret + originalPermifyURL, originalTenant := permifyURL, permifyTenant + originalKeycloakEnabled, originalPermifyEnabled := keycloakEnabled, permifyEnabled + defer func() { + keycloakURL, keycloakRealm, keycloakClientID, keycloakSecret = originalURL, originalRealm, originalClientID, originalSecret + permifyURL, permifyTenant = originalPermifyURL, originalTenant + keycloakEnabled, permifyEnabled = originalKeycloakEnabled, originalPermifyEnabled + }() + + keycloakEnabled, permifyEnabled = true, true + keycloakURL, keycloakRealm, keycloakClientID, keycloakSecret = "", "ndsep", "iam", "secret" + permifyURL, permifyTenant = "http://permify.internal:3476", "ndsep" + if err := validateIAMConfiguration(); err == nil { + t.Fatal("expected enabled Keycloak without URL to be rejected") + } + + keycloakURL = "https://keycloak.internal" + permifyURL = "" + if err := validateIAMConfiguration(); err == nil { + t.Fatal("expected enabled Permify without URL to be rejected") + } + + permifyURL = "http://permify.internal:3476" + if err := validateIAMConfiguration(); err != nil { + t.Fatalf("expected complete non-production IAM configuration to validate: %v", err) + } +} + +func TestCheckPermissionDeniesWhenPermifyIsUnavailable(t *testing.T) { + mu.Lock() + originalOK := permifyOK + permifyOK = false + mu.Unlock() + defer func() { + mu.Lock() + permifyOK = originalOK + mu.Unlock() + }() + + allowed, err := checkPermission("user", "u-1", "read", "report", "r-1") + if err == nil || allowed { + t.Fatalf("expected unavailable Permify to deny with an error, got allowed=%v err=%v", allowed, err) + } +} diff --git a/orchestration/go/cmd/tigerbeetle_ledger/main.go b/orchestration/go/cmd/tigerbeetle_ledger/main.go index 5a542062..f08d5d6d 100644 --- a/orchestration/go/cmd/tigerbeetle_ledger/main.go +++ b/orchestration/go/cmd/tigerbeetle_ledger/main.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "log" - "math" "math/big" "net/http" "os" @@ -17,9 +16,10 @@ import ( ) const ( - ledgerCode uint32 = 1 - accountCode uint16 = 1 - transferCode uint16 = 1 + usdLedgerCode uint32 = 1 + ngnLedgerCode uint32 = 2 + accountCode uint16 = 1 + transferCode uint16 = 1 ) type application struct { @@ -27,14 +27,17 @@ type application struct { } type transactionRequest struct { - OrgID string `json:"org_id"` - PenaltyID string `json:"penalty_id"` - AmountUSD float64 `json:"amount_usd"` - Currency string `json:"currency"` - Type string `json:"type"` - Description string `json:"description"` - IssuedBy string `json:"issued_by"` - Timestamp string `json:"timestamp"` + OrgID string `json:"org_id"` + PenaltyID string `json:"penalty_id"` + Amount json.Number `json:"amount"` + AmountUSD json.Number `json:"amount_usd"` // legacy name accepted only for a safe migration path + Currency string `json:"currency"` + Type string `json:"type"` + DebitAccountID string `json:"debit_account_id"` + CreditAccountID string `json:"credit_account_id"` + Description string `json:"description"` + IssuedBy string `json:"issued_by"` + Timestamp string `json:"timestamp"` } func requiredEnv(name string) string { @@ -53,33 +56,96 @@ func deterministicID(parts ...string) tb.Uint128 { return tb.BytesToUint128(bytes) } -func amountInCents(amount float64) (tb.Uint128, error) { - if math.IsNaN(amount) || math.IsInf(amount, 0) || amount <= 0 { - return tb.Uint128{}, fmt.Errorf("amount_usd must be a positive finite value") +func configuredLedger(currency string) (uint32, string, error) { + switch normalized := strings.ToUpper(strings.TrimSpace(currency)); normalized { + case "USD": + return usdLedgerCode, normalized, nil + case "NGN": + return ngnLedgerCode, normalized, nil + default: + return 0, "", fmt.Errorf("unsupported currency %q; only NGN and USD are configured", currency) + } +} + +func validateExternalID(name, value string) error { + if len(value) == 0 || len(value) > 128 { + return fmt.Errorf("%s must contain 1 to 128 characters", name) } - cents := math.Round(amount * 100) - if cents > float64(^uint64(0)) { - return tb.Uint128{}, fmt.Errorf("amount_usd exceeds supported range") + for _, character := range []byte(value) { + if !(character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '.' || character == '_' || character == ':' || character == '-') { + return fmt.Errorf("%s contains an unsupported character", name) + } } - return tb.ToUint128(uint64(cents)), nil + return nil } -func uint128ToUSD(value tb.Uint128) float64 { - number := value.BigInt() - asFloat, _ := new(big.Float).SetInt(number).Float64() - return asFloat / 100.0 +// amountInMinorUnits accepts a JSON decimal without exponent notation and converts +// it exactly to kobo/cents. Floating point values are never used for ledger amounts. +func amountInMinorUnits(value json.Number) (tb.Uint128, string, error) { + raw := strings.TrimSpace(value.String()) + if raw == "" || strings.HasPrefix(raw, "-") || strings.HasPrefix(raw, "+") || strings.ContainsAny(raw, "eE") { + return tb.Uint128{}, "", fmt.Errorf("amount must be a positive decimal with at most two fractional digits") + } + parts := strings.Split(raw, ".") + if len(parts) > 2 || parts[0] == "" { + return tb.Uint128{}, "", fmt.Errorf("amount must be a positive decimal with at most two fractional digits") + } + whole := parts[0] + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + if len(fraction) > 2 { + return tb.Uint128{}, "", fmt.Errorf("amount cannot include more than two fractional digits") + } + } + for _, part := range []string{whole, fraction} { + for _, character := range []byte(part) { + if character < '0' || character > '9' { + return tb.Uint128{}, "", fmt.Errorf("amount must contain decimal digits only") + } + } + } + fraction += strings.Repeat("0", 2-len(fraction)) + minor, ok := new(big.Int).SetString(strings.TrimLeft(whole+fraction, "0"), 10) + if !ok { + return tb.Uint128{}, "", fmt.Errorf("amount cannot be parsed") + } + if minor.Sign() <= 0 { + return tb.Uint128{}, "", fmt.Errorf("amount must be greater than zero") + } + if minor.BitLen() > 128 { + return tb.Uint128{}, "", fmt.Errorf("amount exceeds TigerBeetle's unsigned 128-bit range") + } + return tb.BigIntToUint128(minor), minor.String(), nil +} + +func uint128ToDecimal(value tb.Uint128) string { + minor := value.BigInt() + quotient, remainder := new(big.Int), new(big.Int) + quotient.QuoRem(minor, big.NewInt(100), remainder) + return fmt.Sprintf("%s.%02d", quotient.String(), remainder.Uint64()) +} + +func signedMinorToDecimal(minor *big.Int) string { + if minor.Sign() >= 0 { + return uint128ToDecimal(tb.BigIntToUint128(minor)) + } + absolute := new(big.Int).Abs(minor) + return "-" + uint128ToDecimal(tb.BigIntToUint128(absolute)) } func validateCreateResults[T any](results []T, describe func(T) string) error { - if len(results) == 0 { - return nil + for _, result := range results { + if detail := describe(result); detail != "" { + return fmt.Errorf("TigerBeetle rejected event: %s", detail) + } } - return fmt.Errorf("TigerBeetle rejected event: %s", describe(results[0])) + return nil } -func (app *application) ensureAccount(id tb.Uint128) error { +func (app *application) ensureAccount(id tb.Uint128, ledger uint32) error { results, err := app.client.CreateAccounts([]tb.Account{{ - ID: id, Ledger: ledgerCode, Code: accountCode, + ID: id, Ledger: ledger, Code: accountCode, Flags: tb.AccountFlags{History: true}.ToUint16(), }}) if err != nil { @@ -93,18 +159,46 @@ func (app *application) ensureAccount(id tb.Uint128) error { }) } -func (app *application) ensureAccounts(orgID string) (tb.Uint128, tb.Uint128, error) { - organization := deterministicID("ndsep", "org", orgID) - treasury := deterministicID("ndsep", "treasury", "revenue") - if err := app.ensureAccount(organization); err != nil { +func (app *application) ensureAccounts(orgID string, ledger uint32, currency string) (tb.Uint128, tb.Uint128, error) { + organization := deterministicID("ndsep", "org", currency, orgID) + treasury := deterministicID("ndsep", "treasury", currency, "revenue") + if err := app.ensureAccount(organization, ledger); err != nil { return tb.Uint128{}, tb.Uint128{}, err } - if err := app.ensureAccount(treasury); err != nil { + if err := app.ensureAccount(treasury, ledger); err != nil { return tb.Uint128{}, tb.Uint128{}, err } return organization, treasury, nil } +func requestAmount(request transactionRequest) (json.Number, error) { + if request.Amount != "" && request.AmountUSD != "" { + return "", fmt.Errorf("provide either amount or amount_usd, not both") + } + if request.Amount != "" { + return request.Amount, nil + } + if request.AmountUSD != "" { + return request.AmountUSD, nil + } + return "", fmt.Errorf("amount is required") +} + +func transferIDForRequest(request transactionRequest, amountMinor string, currency string, idempotencyKey string) (tb.Uint128, error) { + if idempotencyKey != "" { + if len(idempotencyKey) != 64 { + return tb.Uint128{}, fmt.Errorf("Idempotency-Key must be a 64-character SHA-256 hex value") + } + for _, character := range []byte(idempotencyKey) { + if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f' || character >= 'A' && character <= 'F') { + return tb.Uint128{}, fmt.Errorf("Idempotency-Key must be a 64-character SHA-256 hex value") + } + } + return deterministicID("ndsep", "idempotency", strings.ToLower(idempotencyKey)), nil + } + return deterministicID("ndsep", "transfer", request.OrgID, request.PenaltyID, request.Type, currency, amountMinor, request.DebitAccountID, request.CreditAccountID), nil +} + func (app *application) health(w http.ResponseWriter, _ *http.Request) { if err := app.client.Nop(); err != nil { writeError(w, http.StatusServiceUnavailable, fmt.Errorf("TigerBeetle unavailable: %w", err)) @@ -117,27 +211,52 @@ func (app *application) health(w http.ResponseWriter, _ *http.Request) { func (app *application) transaction(w http.ResponseWriter, r *http.Request) { var request transactionRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + decoder := json.NewDecoder(r.Body) + decoder.UseNumber() + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { writeError(w, http.StatusBadRequest, fmt.Errorf("invalid JSON: %w", err)) return } - if strings.TrimSpace(request.OrgID) == "" || strings.TrimSpace(request.PenaltyID) == "" || strings.TrimSpace(request.Type) == "" { - writeError(w, http.StatusBadRequest, fmt.Errorf("org_id, penalty_id, and type are required")) + request.OrgID = strings.TrimSpace(request.OrgID) + request.PenaltyID = strings.TrimSpace(request.PenaltyID) + request.Type = strings.TrimSpace(request.Type) + if err := validateExternalID("org_id", request.OrgID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateExternalID("penalty_id", request.PenaltyID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if request.Type == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("type is required")) return } - if request.Currency == "" { - request.Currency = "USD" + ledger, currency, err := configuredLedger(request.Currency) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return } - if request.Currency != "USD" { - writeError(w, http.StatusBadRequest, fmt.Errorf("only USD is configured for this TigerBeetle ledger")) + decimal, err := requestAmount(request) + if err != nil { + writeError(w, http.StatusBadRequest, err) return } - amount, err := amountInCents(request.AmountUSD) + amount, amountMinor, err := amountInMinorUnits(decimal) if err != nil { writeError(w, http.StatusBadRequest, err) return } - organization, treasury, err := app.ensureAccounts(request.OrgID) + if request.Timestamp != "" { + if _, err := time.Parse(time.RFC3339, request.Timestamp); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("timestamp must use RFC 3339 format")) + return + } + } + + organization, treasury, err := app.ensureAccounts(request.OrgID, ledger, currency) if err != nil { writeError(w, http.StatusServiceUnavailable, err) return @@ -147,14 +266,37 @@ func (app *application) transaction(w http.ResponseWriter, r *http.Request) { case "penalty", "fine", "escrow": case "settlement", "refund": debit, credit = treasury, organization + case "transfer": + if err := validateExternalID("debit_account_id", request.DebitAccountID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateExternalID("credit_account_id", request.CreditAccountID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + debit = deterministicID("ndsep", "account", currency, request.DebitAccountID) + credit = deterministicID("ndsep", "account", currency, request.CreditAccountID) + if err := app.ensureAccount(debit, ledger); err != nil { + writeError(w, http.StatusServiceUnavailable, err) + return + } + if err := app.ensureAccount(credit, ledger); err != nil { + writeError(w, http.StatusServiceUnavailable, err) + return + } default: writeError(w, http.StatusBadRequest, fmt.Errorf("unsupported transaction type %q", request.Type)) return } - transferID := deterministicID("ndsep", "transfer", request.OrgID, request.PenaltyID, request.Type, fmt.Sprintf("%.2f", request.AmountUSD)) + transferID, err := transferIDForRequest(request, amountMinor, currency, strings.TrimSpace(r.Header.Get("Idempotency-Key"))) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } results, err := app.client.CreateTransfers([]tb.Transfer{{ ID: transferID, DebitAccountID: debit, CreditAccountID: credit, - Amount: amount, Ledger: ledgerCode, Code: transferCode, + Amount: amount, Ledger: ledger, Code: transferCode, }}) if err != nil { writeError(w, http.StatusServiceUnavailable, fmt.Errorf("create TigerBeetle transfer: %w", err)) @@ -171,17 +313,22 @@ func (app *application) transaction(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusCreated, map[string]interface{}{ "success": true, "transaction_id": transferID.String(), "ledger_entry_id": transferID.String(), - "idempotent": len(results) > 0, "status": "posted", + "idempotent": len(results) > 0, "status": "posted", "currency": currency, "amount_minor": amountMinor, }) } func (app *application) balance(w http.ResponseWriter, r *http.Request) { orgID := mux.Vars(r)["org_id"] - if orgID == "" { - writeError(w, http.StatusBadRequest, fmt.Errorf("org_id is required")) + if err := validateExternalID("org_id", orgID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + _, currency, err := configuredLedger(r.URL.Query().Get("currency")) + if err != nil { + writeError(w, http.StatusBadRequest, err) return } - accountID := deterministicID("ndsep", "org", orgID) + accountID := deterministicID("ndsep", "org", currency, orgID) accounts, err := app.client.LookupAccounts([]tb.Uint128{accountID}) if err != nil { writeError(w, http.StatusServiceUnavailable, fmt.Errorf("lookup TigerBeetle account: %w", err)) @@ -192,12 +339,15 @@ func (app *application) balance(w http.ResponseWriter, r *http.Request) { return } account := accounts[0] - issued := uint128ToUSD(account.DebitsPosted) - paid := uint128ToUSD(account.CreditsPosted) writeJSON(w, http.StatusOK, map[string]interface{}{ - "orgId": orgID, "total_penalties_issued": issued, "total_penalties_paid": paid, - "total_escrow_held": uint128ToUSD(account.DebitsPending), "total_refunds": 0.0, - "net_liability": issued - paid, "currency": "USD", "lastUpdated": time.Now().UTC(), + "org_id": orgID, + "currency": currency, + "debits_posted": uint128ToDecimal(account.DebitsPosted), + "credits_posted": uint128ToDecimal(account.CreditsPosted), + "debits_pending": uint128ToDecimal(account.DebitsPending), + "credits_pending": uint128ToDecimal(account.CreditsPending), + "net_position": signedMinorToDecimal(new(big.Int).Sub(account.CreditsPosted.BigInt(), account.DebitsPosted.BigInt())), + "last_updated": time.Now().UTC(), }) } diff --git a/orchestration/go/cmd/tigerbeetle_ledger/main_test.go b/orchestration/go/cmd/tigerbeetle_ledger/main_test.go new file mode 100644 index 00000000..220f0e64 --- /dev/null +++ b/orchestration/go/cmd/tigerbeetle_ledger/main_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" + tb "github.com/tigerbeetle/tigerbeetle-go" +) + +type fakeTigerBeetleClient struct { + accountResults []tb.CreateAccountResult + transferResults []tb.CreateTransferResult + createdAccounts []tb.Account + createdTransfer []tb.Transfer + lookupAccounts []tb.Account +} + +func (f *fakeTigerBeetleClient) CreateAccounts(accounts []tb.Account) ([]tb.CreateAccountResult, error) { + f.createdAccounts = append(f.createdAccounts, accounts...) + return f.accountResults, nil +} + +func (f *fakeTigerBeetleClient) CreateTransfers(transfers []tb.Transfer) ([]tb.CreateTransferResult, error) { + f.createdTransfer = append(f.createdTransfer, transfers...) + return f.transferResults, nil +} + +func (f *fakeTigerBeetleClient) LookupAccounts([]tb.Uint128) ([]tb.Account, error) { + return f.lookupAccounts, nil +} + +func (f *fakeTigerBeetleClient) LookupTransfers([]tb.Uint128) ([]tb.Transfer, error) { + return nil, nil +} + +func (f *fakeTigerBeetleClient) GetAccountTransfers(tb.AccountFilter) ([]tb.Transfer, error) { + return nil, nil +} + +func (f *fakeTigerBeetleClient) GetAccountBalances(tb.AccountFilter) ([]tb.AccountBalance, error) { + return nil, nil +} + +func (f *fakeTigerBeetleClient) QueryAccounts(tb.QueryFilter) ([]tb.Account, error) { + return nil, nil +} + +func (f *fakeTigerBeetleClient) QueryTransfers(tb.QueryFilter) ([]tb.Transfer, error) { + return nil, nil +} + +func (f *fakeTigerBeetleClient) GetChangeEvents(tb.ChangeEventsFilter) ([]tb.ChangeEvent, error) { + return nil, nil +} + +func (f *fakeTigerBeetleClient) Nop() error { return nil } +func (f *fakeTigerBeetleClient) Close() {} + +func decodeResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var response map[string]interface{} + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v; body=%q", err, recorder.Body.String()) + } + return response +} + +func TestAmountInMinorUnitsUsesExactDecimals(t *testing.T) { + amount, minor, err := amountInMinorUnits(json.Number("100.25")) + if err != nil { + t.Fatalf("expected valid amount: %v", err) + } + if minor != "10025" || amount.BigInt().String() != "10025" { + t.Fatalf("expected 10025 minor units, got minor=%s amount=%s", minor, amount.BigInt()) + } + + for _, value := range []json.Number{"0", "1.234", "1e2", "-1", "abc"} { + if _, _, err := amountInMinorUnits(value); err == nil { + t.Fatalf("expected %q to be rejected", value) + } + } +} + +func TestValidateCreateResultsAcceptsOnlyExactIdempotentStatus(t *testing.T) { + if err := validateCreateResults( + []tb.CreateAccountResult{{Status: tb.AccountExists}}, + func(result tb.CreateAccountResult) string { + if result.Status == tb.AccountExists { + return "" + } + return result.Status.String() + }, + ); err != nil { + t.Fatalf("expected AccountExists to be an idempotent success: %v", err) + } + + if err := validateCreateResults( + []tb.CreateTransferResult{{Status: tb.TransferExistsWithDifferentAmount}}, + func(result tb.CreateTransferResult) string { return result.Status.String() }, + ); err == nil || !strings.Contains(err.Error(), "TransferExistsWithDifferentAmount") { + t.Fatalf("expected conflicting duplicate rejection, got %v", err) + } +} + +func TestTransactionPostsExactNGNTransferAndAcknowledgesIdempotency(t *testing.T) { + client := &fakeTigerBeetleClient{ + accountResults: []tb.CreateAccountResult{{Status: tb.AccountExists}}, + transferResults: []tb.CreateTransferResult{{Status: tb.TransferExists}}, + } + app := &application{client: client} + + request := httptest.NewRequest(http.MethodPost, "/transaction", bytes.NewBufferString(`{ + "org_id":"bank-000001", + "penalty_id":"nip-ref-001", + "amount":100.25, + "currency":"NGN", + "type":"transfer", + "debit_account_id":"bank-000001", + "credit_account_id":"bank-000002" + }`)) + request.Header.Set("Idempotency-Key", strings.Repeat("a", 64)) + recorder := httptest.NewRecorder() + app.transaction(recorder, request) + + if recorder.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", recorder.Code, recorder.Body.String()) + } + if len(client.createdTransfer) != 1 { + t.Fatalf("expected one TigerBeetle transfer, got %d", len(client.createdTransfer)) + } + transfer := client.createdTransfer[0] + if transfer.Ledger != ngnLedgerCode || transfer.Amount.BigInt().String() != "10025" { + t.Fatalf("expected exact NGN ledger transfer, got ledger=%d amount=%s", transfer.Ledger, transfer.Amount.BigInt()) + } + response := decodeResponse(t, recorder) + if response["idempotent"] != true || response["currency"] != "NGN" || response["amount_minor"] != "10025" { + t.Fatalf("expected durable idempotent response, got %#v", response) + } +} + +func TestTransactionRejectsFractionalOverflowAndUnknownFields(t *testing.T) { + app := &application{client: &fakeTigerBeetleClient{}} + for _, body := range []string{ + `{"org_id":"org-1","penalty_id":"p-1","amount":1.234,"currency":"NGN","type":"fine"}`, + `{"org_id":"org-1","penalty_id":"p-1","amount":1,"currency":"NGN","type":"fine","unexpected":true}`, + } { + request := httptest.NewRequest(http.MethodPost, "/transaction", bytes.NewBufferString(body)) + recorder := httptest.NewRecorder() + app.transaction(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for %s, got %d: %s", body, recorder.Code, recorder.Body.String()) + } + } +} + +func TestBalanceReportsSignedExactNetPosition(t *testing.T) { + client := &fakeTigerBeetleClient{lookupAccounts: []tb.Account{{ + DebitsPosted: tb.ToUint128(10025), + CreditsPosted: tb.ToUint128(5000), + }}} + app := &application{client: client} + request := httptest.NewRequest(http.MethodGet, "/balance/org-1?currency=NGN", nil) + request = mux.SetURLVars(request, map[string]string{"org_id": "org-1"}) + recorder := httptest.NewRecorder() + app.balance(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + response := decodeResponse(t, recorder) + if response["debits_posted"] != "100.25" || response["credits_posted"] != "50.00" || response["net_position"] != "-50.25" { + t.Fatalf("expected exact signed balance, got %#v", response) + } +} diff --git a/orchestration/go/go.mod b/orchestration/go/go.mod index 9a8590b5..e6eed880 100644 --- a/orchestration/go/go.mod +++ b/orchestration/go/go.mod @@ -50,15 +50,15 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.9.0 // indirect go.temporal.io/api v1.29.1 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/grpc v1.82.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/orchestration/go/go.sum b/orchestration/go/go.sum index 0d4fb961..b85e8781 100644 --- a/orchestration/go/go.sum +++ b/orchestration/go/go.sum @@ -151,16 +151,16 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.temporal.io/api v1.29.1 h1:L722DCy3xCzpTe3Rvh1sFC9kcSaMJXqvodCF+swHGtQ= go.temporal.io/api v1.29.1/go.mod h1:wZtsUJ3PySASGWbpXBWYVKJ4aHB2ZODEn/xNcTr9HRs= go.temporal.io/sdk v1.26.0 h1:QAi7irgKvJI+5cKmvy+1lkdCDJJDDNpIQAoXdr3dcyM= @@ -173,8 +173,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= @@ -200,8 +200,8 @@ golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -210,8 +210,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -225,8 +225,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -235,8 +235,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -260,17 +260,17 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/orchestration/go/pkg/kafka/client.go b/orchestration/go/pkg/kafka/client.go index bd38e235..04ed7c39 100644 --- a/orchestration/go/pkg/kafka/client.go +++ b/orchestration/go/pkg/kafka/client.go @@ -2,101 +2,294 @@ package kafka import ( -"encoding/json" -"fmt" -"log" -"os" -"strings" -"time" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/IBM/sarama" ) const ( -TopicOrgRegistered = "ndsep.org.registered" -TopicComplianceAssessed = "ndsep.compliance.assessed" -TopicViolationDetected = "ndsep.violation.detected" -TopicPenaltyIssued = "ndsep.penalty.issued" -TopicPenaltyPaid = "ndsep.penalty.paid" -TopicTransferRequested = "ndsep.transfer.requested" -TopicTransferApproved = "ndsep.transfer.approved" -TopicTransferRejected = "ndsep.transfer.rejected" -TopicNetworkBlock = "ndsep.network.block" -TopicBGPHijack = "ndsep.bgp.hijack" -TopicThreatIntel = "ndsep.threat.intel" -TopicIncidentCreated = "ndsep.incident.created" -TopicDataResidencyViolation = "ndsep.residency.violation" -TopicMLRiskUpdated = "ndsep.ml.risk_score_updated" -TopicAuditTrail = "ndsep.audit.trail" -TopicCertificateIssued = "ndsep.certificate.issued" -TopicRevenueDistributed = "ndsep.revenue.distributed" -TopicWorkflowStarted = "ndsep.workflow.started" -TopicWorkflowCompleted = "ndsep.workflow.completed" -TopicPenaltyDisputed = "ndsep.penalty.disputed" -TopicIXPEnforcement = "ndsep.ixp.enforcement" -TopicLakehouseIngested = "ndsep.lakehouse.ingested" -TopicMetricsScraped = "ndsep.metrics.scraped" -TopicPCAPCaptured = "ndsep.pcap.captured" -TopicReconciliationDone = "ndsep.reconciliation.done" -TopicIncidentEscalated = "ndsep.incident.escalated" -TopicStreamingProcessed = "ndsep.streaming.processed" -TopicViolationRemediated = "ndsep.violation.remediated" -TopicSLABreachPredicted = "ndsep.sla.breach_predicted" -TopicRegulatorySubmitted = "ndsep.regulatory.submitted" + TopicOrgRegistered = "ndsep.org.registered" + TopicComplianceAssessed = "ndsep.compliance.assessed" + TopicViolationDetected = "ndsep.violation.detected" + TopicPenaltyIssued = "ndsep.penalty.issued" + TopicPenaltyPaid = "ndsep.penalty.paid" + TopicTransferRequested = "ndsep.transfer.requested" + TopicTransferApproved = "ndsep.transfer.approved" + TopicTransferRejected = "ndsep.transfer.rejected" + TopicNetworkBlock = "ndsep.network.block" + TopicBGPHijack = "ndsep.bgp.hijack" + TopicThreatIntel = "ndsep.threat.intel" + TopicIncidentCreated = "ndsep.incident.created" + TopicDataResidencyViolation = "ndsep.residency.violation" + TopicMLRiskUpdated = "ndsep.ml.risk_score_updated" + TopicAuditTrail = "ndsep.audit.trail" + TopicCertificateIssued = "ndsep.certificate.issued" + TopicRevenueDistributed = "ndsep.revenue.distributed" + TopicWorkflowStarted = "ndsep.workflow.started" + TopicWorkflowCompleted = "ndsep.workflow.completed" + TopicPenaltyDisputed = "ndsep.penalty.disputed" + TopicIXPEnforcement = "ndsep.ixp.enforcement" + TopicLakehouseIngested = "ndsep.lakehouse.ingested" + TopicMetricsScraped = "ndsep.metrics.scraped" + TopicPCAPCaptured = "ndsep.pcap.captured" + TopicReconciliationDone = "ndsep.reconciliation.done" + TopicIncidentEscalated = "ndsep.incident.escalated" + TopicStreamingProcessed = "ndsep.streaming.processed" + TopicViolationRemediated = "ndsep.violation.remediated" + TopicSLABreachPredicted = "ndsep.sla.breach_predicted" + TopicRegulatorySubmitted = "ndsep.regulatory.submitted" ) func AllTopics() []string { -return []string{ -TopicOrgRegistered, TopicComplianceAssessed, TopicViolationDetected, -TopicPenaltyIssued, TopicPenaltyPaid, TopicTransferRequested, -TopicTransferApproved, TopicTransferRejected, TopicNetworkBlock, -TopicBGPHijack, TopicThreatIntel, TopicIncidentCreated, -TopicDataResidencyViolation, TopicMLRiskUpdated, TopicAuditTrail, -TopicCertificateIssued, TopicRevenueDistributed, TopicWorkflowStarted, -TopicWorkflowCompleted, TopicPenaltyDisputed, TopicIXPEnforcement, -TopicLakehouseIngested, TopicMetricsScraped, TopicPCAPCaptured, -TopicReconciliationDone, TopicIncidentEscalated, TopicStreamingProcessed, -TopicViolationRemediated, TopicSLABreachPredicted, TopicRegulatorySubmitted, -} + return []string{ + TopicOrgRegistered, TopicComplianceAssessed, TopicViolationDetected, + TopicPenaltyIssued, TopicPenaltyPaid, TopicTransferRequested, + TopicTransferApproved, TopicTransferRejected, TopicNetworkBlock, + TopicBGPHijack, TopicThreatIntel, TopicIncidentCreated, + TopicDataResidencyViolation, TopicMLRiskUpdated, TopicAuditTrail, + TopicCertificateIssued, TopicRevenueDistributed, TopicWorkflowStarted, + TopicWorkflowCompleted, TopicPenaltyDisputed, TopicIXPEnforcement, + TopicLakehouseIngested, TopicMetricsScraped, TopicPCAPCaptured, + TopicReconciliationDone, TopicIncidentEscalated, TopicStreamingProcessed, + TopicViolationRemediated, TopicSLABreachPredicted, TopicRegulatorySubmitted, + } } type Event struct { -ID string `json:"id"` -Topic string `json:"topic"` -JourneyID string `json:"journey_id"` -Timestamp time.Time `json:"timestamp"` -Source string `json:"source"` -Payload json.RawMessage `json:"payload"` + ID string `json:"id"` + Topic string `json:"topic"` + JourneyID string `json:"journey_id"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Payload json.RawMessage `json:"payload"` } type Client struct { -brokers string -logger *log.Logger + brokers []string + logger *log.Logger + producer sarama.SyncProducer + producerMu sync.Mutex + producerInit func([]string, *sarama.Config) (sarama.SyncProducer, error) +} + +func isProduction() bool { + return strings.EqualFold(os.Getenv("APP_ENV"), "production") || strings.EqualFold(os.Getenv("NODE_ENV"), "production") } func New() *Client { -brokers := os.Getenv("KAFKA_BROKERS") -if brokers == "" { -brokers = "localhost:9092" + brokers := strings.TrimSpace(os.Getenv("KAFKA_BROKERS")) + if brokers == "" && !isProduction() { + brokers = "localhost:9092" + } + return &Client{ + brokers: splitBrokers(brokers), + logger: log.New(os.Stdout, "[kafka] ", log.LstdFlags), + producerInit: sarama.NewSyncProducer, + } } -return &Client{brokers: brokers, logger: log.New(os.Stdout, "[kafka] ", log.LstdFlags)} + +func splitBrokers(raw string) []string { + var brokers []string + for _, broker := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(broker); trimmed != "" { + brokers = append(brokers, trimmed) + } + } + return brokers } -func (c *Client) Brokers() []string { return strings.Split(c.brokers, ",") } +func (c *Client) Brokers() []string { + return append([]string(nil), c.brokers...) +} -func (c *Client) Publish(topic, journeyID string, payload interface{}) error { -raw, err := json.Marshal(payload) -if err != nil { -return fmt.Errorf("marshal payload: %w", err) +func kafkaEnabled() bool { + return strings.EqualFold(os.Getenv("KAFKA_ENABLED"), "true") +} + +func validateTopic(topic string) error { + if topic == "" || len(topic) > 249 { + return errors.New("Kafka topic is required and must be at most 249 characters") + } + for _, allowed := range AllTopics() { + if topic == allowed { + return nil + } + } + return fmt.Errorf("Kafka topic is not in the NDSEP allow-list: %s", topic) } -event := Event{ -ID: fmt.Sprintf("%s-%d", journeyID, time.Now().UnixNano()), -Topic: topic, JourneyID: journeyID, -Timestamp: time.Now().UTC(), Source: "ndsep-orchestration", Payload: raw, + +func (c *Client) config() (*sarama.Config, error) { + if !kafkaEnabled() { + return nil, errors.New("Kafka is disabled; set KAFKA_ENABLED=true only in an approved environment") + } + if len(c.brokers) == 0 { + return nil, errors.New("KAFKA_BROKERS must contain at least one broker") + } + if isProduction() { + for _, broker := range c.brokers { + if strings.HasPrefix(broker, "localhost:") || strings.HasPrefix(broker, "127.0.0.1:") { + return nil, errors.New("production Kafka brokers must not target localhost") + } + } + if !strings.EqualFold(os.Getenv("KAFKA_TLS_ENABLED"), "true") { + return nil, errors.New("production Kafka requires KAFKA_TLS_ENABLED=true") + } + if os.Getenv("KAFKA_SASL_USERNAME") == "" || os.Getenv("KAFKA_SASL_PASSWORD") == "" { + return nil, errors.New("production Kafka requires KAFKA_SASL_USERNAME and KAFKA_SASL_PASSWORD") + } + } + + config := sarama.NewConfig() + config.Version = sarama.V3_6_0_0 + config.ClientID = "ndsep-orchestration" + config.Producer.RequiredAcks = sarama.WaitForAll + config.Producer.Retry.Max = 3 + config.Producer.Return.Successes = true + config.Producer.Return.Errors = true + config.Producer.Idempotent = true + config.Net.MaxOpenRequests = 1 + config.Net.DialTimeout = 5 * time.Second + config.Net.ReadTimeout = 10 * time.Second + config.Net.WriteTimeout = 10 * time.Second + + if strings.EqualFold(os.Getenv("KAFKA_TLS_ENABLED"), "true") { + config.Net.TLS.Enable = true + config.Net.TLS.Config = &tls.Config{MinVersion: tls.VersionTLS12} + } + if os.Getenv("KAFKA_SASL_USERNAME") != "" || os.Getenv("KAFKA_SASL_PASSWORD") != "" { + if os.Getenv("KAFKA_SASL_USERNAME") == "" || os.Getenv("KAFKA_SASL_PASSWORD") == "" { + return nil, errors.New("KAFKA_SASL_USERNAME and KAFKA_SASL_PASSWORD must be configured together") + } + config.Net.SASL.Enable = true + config.Net.SASL.User = os.Getenv("KAFKA_SASL_USERNAME") + config.Net.SASL.Password = os.Getenv("KAFKA_SASL_PASSWORD") + config.Net.SASL.Mechanism = sarama.SASLTypePlaintext + } + return config, nil +} + +func (c *Client) ensureProducer() (sarama.SyncProducer, error) { + c.producerMu.Lock() + defer c.producerMu.Unlock() + if c.producer != nil { + return c.producer, nil + } + config, err := c.config() + if err != nil { + return nil, err + } + producer, err := c.producerInit(c.brokers, config) + if err != nil { + return nil, fmt.Errorf("connect to Kafka broker: %w", err) + } + c.producer = producer + return c.producer, nil } -eventJSON, _ := json.Marshal(event) -c.logger.Printf("PUBLISH topic=%s journey=%s payload=%s", topic, journeyID, string(eventJSON)) -return nil + +func (c *Client) Close() error { + c.producerMu.Lock() + defer c.producerMu.Unlock() + if c.producer == nil { + return nil + } + err := c.producer.Close() + c.producer = nil + return err +} + +func (c *Client) Publish(topic, journeyID string, payload interface{}) error { + if err := validateTopic(topic); err != nil { + return err + } + if strings.TrimSpace(journeyID) == "" { + return errors.New("journey ID is required for a Kafka event") + } + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal Kafka payload: %w", err) + } + event := Event{ + ID: fmt.Sprintf("%s-%d", journeyID, time.Now().UTC().UnixNano()), + Topic: topic, + JourneyID: journeyID, + Timestamp: time.Now().UTC(), + Source: "ndsep-orchestration", + Payload: raw, + } + eventJSON, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal Kafka event: %w", err) + } + producer, err := c.ensureProducer() + if err != nil { + return err + } + partition, offset, err := producer.SendMessage(&sarama.ProducerMessage{ + Topic: topic, + Key: sarama.StringEncoder(journeyID), + Value: sarama.ByteEncoder(eventJSON), + }) + if err != nil { + return fmt.Errorf("required Kafka delivery failed: %w", err) + } + c.logger.Printf("PUBLISHED topic=%s journey=%s event_id=%s partition=%d offset=%d", topic, journeyID, event.ID, partition, offset) + return nil } -func (c *Client) Subscribe(topic, groupID string, handler func(Event) error) { -c.logger.Printf("SUBSCRIBE topic=%s group=%s", topic, groupID) +// Subscribe starts a real Sarama partition consumer. Callers own process lifecycle and should +// terminate the process only after closing its Kafka client. Handler failures are logged and the +// message remains observable in broker retention; this primitive does not claim exactly-once processing. +func (c *Client) Subscribe(topic, groupID string, handler func(Event) error) error { + if err := validateTopic(topic); err != nil { + return err + } + if strings.TrimSpace(groupID) == "" { + return errors.New("Kafka group ID is required") + } + if handler == nil { + return errors.New("Kafka subscription handler is required") + } + config, err := c.config() + if err != nil { + return err + } + consumer, err := sarama.NewConsumer(c.brokers, config) + if err != nil { + return fmt.Errorf("connect Kafka consumer: %w", err) + } + partitions, err := consumer.Partitions(topic) + if err != nil { + _ = consumer.Close() + return fmt.Errorf("list Kafka partitions: %w", err) + } + for _, partition := range partitions { + partitionConsumer, err := consumer.ConsumePartition(topic, partition, sarama.OffsetNewest) + if err != nil { + _ = consumer.Close() + return fmt.Errorf("consume Kafka partition %d: %w", partition, err) + } + go func(pc sarama.PartitionConsumer) { + defer pc.Close() + for message := range pc.Messages() { + var event Event + if err := json.Unmarshal(message.Value, &event); err != nil { + c.logger.Printf("REJECTED malformed Kafka event topic=%s partition=%d offset=%d: %v", message.Topic, message.Partition, message.Offset, err) + continue + } + if err := handler(event); err != nil { + c.logger.Printf("Kafka handler failed event_id=%s topic=%s: %v", event.ID, message.Topic, err) + } + } + }(partitionConsumer) + } + c.logger.Printf("SUBSCRIBED topic=%s group=%s partitions=%d", topic, groupID, len(partitions)) + return nil } diff --git a/orchestration/go/pkg/kafka/client_test.go b/orchestration/go/pkg/kafka/client_test.go new file mode 100644 index 00000000..4cf57221 --- /dev/null +++ b/orchestration/go/pkg/kafka/client_test.go @@ -0,0 +1,55 @@ +package kafka + +import ( + "strings" + "testing" +) + +func TestValidateTopicRequiresNDSEPAllowList(t *testing.T) { + if err := validateTopic(TopicAuditTrail); err != nil { + t.Fatalf("allow-listed topic rejected: %v", err) + } + if err := validateTopic("ndsep.unapproved.topic"); err == nil { + t.Fatal("unapproved topic was accepted") + } +} + +func TestKafkaDisabledByDefault(t *testing.T) { + t.Setenv("APP_ENV", "test") + t.Setenv("NODE_ENV", "test") + t.Setenv("KAFKA_ENABLED", "false") + t.Setenv("KAFKA_BROKERS", "broker.internal:9093") + client := New() + if _, err := client.config(); err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatalf("disabled Kafka error = %v, want disabled configuration error", err) + } +} + +func TestProductionKafkaRequiresTLSAndSASL(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("NODE_ENV", "production") + t.Setenv("KAFKA_ENABLED", "true") + t.Setenv("KAFKA_BROKERS", "broker.internal:9093") + t.Setenv("KAFKA_TLS_ENABLED", "false") + t.Setenv("KAFKA_SASL_USERNAME", "") + t.Setenv("KAFKA_SASL_PASSWORD", "") + client := New() + if _, err := client.config(); err == nil || !strings.Contains(err.Error(), "KAFKA_TLS_ENABLED") { + t.Fatalf("production TLS error = %v, want TLS configuration error", err) + } + + t.Setenv("KAFKA_TLS_ENABLED", "true") + if _, err := client.config(); err == nil || !strings.Contains(err.Error(), "KAFKA_SASL_USERNAME") { + t.Fatalf("production SASL error = %v, want SASL configuration error", err) + } + + t.Setenv("KAFKA_SASL_USERNAME", "ndsep-producer") + t.Setenv("KAFKA_SASL_PASSWORD", "test-password") + config, err := client.config() + if err != nil { + t.Fatalf("secure production config rejected: %v", err) + } + if !config.Net.TLS.Enable || !config.Net.SASL.Enable || !config.Producer.Idempotent || config.Producer.RequiredAcks != -1 { + t.Fatalf("secure config misses required reliability/security controls: %#v", config) + } +} diff --git a/orchestration/go/pkg/permify/client.go b/orchestration/go/pkg/permify/client.go index 3459466f..e1e09c4b 100644 --- a/orchestration/go/pkg/permify/client.go +++ b/orchestration/go/pkg/permify/client.go @@ -1,83 +1,82 @@ package permify import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "strings" - "time" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" ) type CheckRequest struct { - SubjectType string `json:"subject_type"` - SubjectID string `json:"subject_id"` - Permission string `json:"permission"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + Permission string `json:"permission"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` } type CheckResponse struct { - Allowed bool `json:"allowed"` - Reason string `json:"reason,omitempty"` + Allowed bool `json:"allowed"` + Reason string `json:"reason,omitempty"` } type Client struct { - baseURL string - httpClient *http.Client - logger *log.Logger + baseURL string + httpClient *http.Client + logger *log.Logger } func New() *Client { - url := os.Getenv("PERMIFY_URL") - if url == "" { - url = "http://localhost:3476" - } - return &Client{ - baseURL: url, - httpClient: &http.Client{Timeout: 3 * time.Second}, - logger: log.New(os.Stdout, "[permify] ", log.LstdFlags), - } + return &Client{ + baseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("PERMIFY_URL")), "/"), + httpClient: &http.Client{Timeout: 3 * time.Second}, + logger: log.New(os.Stdout, "[permify] ", log.LstdFlags), + } } // Check delegates every authorization decision to Permify. Any dependency, // protocol, or decoding failure is an explicit error; callers must deny. func (c *Client) Check(ctx context.Context, req CheckRequest) (bool, error) { - body, err := json.Marshal(req) - if err != nil { - return false, fmt.Errorf("marshal Permify authorization request: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, - fmt.Sprintf("%s/v1/permissions/check", c.baseURL), strings.NewReader(string(body))) - if err != nil { - return false, fmt.Errorf("build Permify authorization request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return false, fmt.Errorf("Permify authorization service unavailable: %w", err) - } - defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return false, fmt.Errorf("read Permify authorization response: %w", err) - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return false, fmt.Errorf("Permify authorization response HTTP %d: %s", resp.StatusCode, string(respBody)) - } - var checkResp CheckResponse - if err := json.Unmarshal(respBody, &checkResp); err != nil { - return false, fmt.Errorf("decode Permify authorization response: %w", err) - } - return checkResp.Allowed, nil + if c.baseURL == "" { + return false, fmt.Errorf("PERMIFY_URL is required for authorization decisions") + } + body, err := json.Marshal(req) + if err != nil { + return false, fmt.Errorf("marshal Permify authorization request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf("%s/v1/permissions/check", c.baseURL), strings.NewReader(string(body))) + if err != nil { + return false, fmt.Errorf("build Permify authorization request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return false, fmt.Errorf("Permify authorization service unavailable: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("read Permify authorization response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return false, fmt.Errorf("Permify authorization response HTTP %d: %s", resp.StatusCode, string(respBody)) + } + var checkResp CheckResponse + if err := json.Unmarshal(respBody, &checkResp); err != nil { + return false, fmt.Errorf("decode Permify authorization response: %w", err) + } + return checkResp.Allowed, nil } func (c *Client) HasPermission(ctx context.Context, role, permission string) (bool, error) { - return c.Check(ctx, CheckRequest{ - SubjectType: role, SubjectID: "*", Permission: permission, - ResourceType: "platform", ResourceID: "ndsep", - }) + return c.Check(ctx, CheckRequest{ + SubjectType: role, SubjectID: "*", Permission: permission, + ResourceType: "platform", ResourceID: "ndsep", + }) } diff --git a/orchestration/go/pkg/permify/client_test.go b/orchestration/go/pkg/permify/client_test.go new file mode 100644 index 00000000..12de5d14 --- /dev/null +++ b/orchestration/go/pkg/permify/client_test.go @@ -0,0 +1,26 @@ +package permify + +import ( + "context" + "os" + "testing" +) + +func TestCheckFailsClosedWithoutPermifyURL(t *testing.T) { + original, existed := os.LookupEnv("PERMIFY_URL") + _ = os.Unsetenv("PERMIFY_URL") + defer func() { + if existed { + _ = os.Setenv("PERMIFY_URL", original) + } else { + _ = os.Unsetenv("PERMIFY_URL") + } + }() + + allowed, err := New().Check(context.Background(), CheckRequest{ + SubjectType: "user", SubjectID: "u-1", Permission: "read", ResourceType: "report", ResourceID: "r-1", + }) + if err == nil || allowed { + t.Fatalf("expected absent PERMIFY_URL to deny before network transport, got allowed=%v err=%v", allowed, err) + } +} diff --git a/orchestration/python/dpco_analytics/Dockerfile b/orchestration/python/dpco_analytics/Dockerfile index 60f48f19..2672599f 100644 --- a/orchestration/python/dpco_analytics/Dockerfile +++ b/orchestration/python/dpco_analytics/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.11-slim WORKDIR /app -RUN pip install --no-cache-dir fastapi uvicorn pydantic requests kafka-python redis pyarrow boto3 +RUN pip install --no-cache-dir fastapi uvicorn pydantic requests kafka-python redis pyarrow boto3 "psycopg[binary,pool]" COPY . . EXPOSE 8330 USER nobody diff --git a/orchestration/python/dpco_analytics/service.py b/orchestration/python/dpco_analytics/service.py index 5f3c597c..1b5f3197 100644 --- a/orchestration/python/dpco_analytics/service.py +++ b/orchestration/python/dpco_analytics/service.py @@ -1,50 +1,33 @@ -#!/usr/bin/env python3 """ -NDSEP DPCO Analytics Service (Python) — Port 8330 -================================================== -Consumes DPCO events from Kafka and Fluvio, ingests into the Lakehouse (Apache -Iceberg via MinIO/S3), and exposes compliance trend analytics via REST. - -Middleware integrations: - - Kafka (kafka-python): consumes ndsep.dpco.* topics for audit, registry, - verification events; produces to ndsep.dpco.analytics.events - - Fluvio (HTTP proxy): publishes real-time DPCO compliance metrics to - Fluvio edge stream for low-latency dashboards - - Lakehouse / Apache Iceberg (REST catalog + MinIO S3): writes Parquet - partitioned by year/month/day to dpco_audit_events, dpco_registry_events, - dpco_compliance_scores tables - - Redis (requests HTTP): caches analytics query results with 5-min TTL - - Dapr (HTTP sidecar): subscribes to dpco.* pub/sub topics via Dapr - CloudEvents endpoint and publishes analytics results back - - Graceful degradation: all middleware failures are logged and skipped - -REST Endpoints: - GET /health — service health + middleware status - GET /api/dpco/analytics/trends — DPCO compliance trend (6-month) - GET /api/dpco/analytics/portfolio — per-DPCO client portfolio stats - GET /api/dpco/analytics/sla — SLA breach rates by DPCO - GET /api/dpco/analytics/heatmap — audit frequency heatmap data - POST /api/dpco/analytics/ingest — manual event ingestion endpoint - GET /metrics — operational metrics +NDSEP DPCO Analytics Service — durable PostgreSQL implementation. + +Authoritative DPCO analytics events and aggregates are persisted to PostgreSQL. +Kafka, Fluvio, Dapr, Redis, and Lakehouse are integrations around that source of +truth; none is permitted to replace it with process-local state or demo data. """ -import os +from __future__ import annotations + +import hashlib +import hmac import io import json -import uuid -import time import logging +import os import threading -import collections -from datetime import datetime, timezone, timedelta -from typing import Any, Dict, List, Optional +import time +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Any, Iterator, Optional import requests -from fastapi import FastAPI, Request -from pydantic import BaseModel +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool import uvicorn -# ─── Logging ────────────────────────────────────────────────────────────────── - logging.basicConfig( level=logging.INFO, format="[dpco-analytics] %(asctime)s %(levelname)s %(message)s", @@ -52,156 +35,336 @@ ) log = logging.getLogger(__name__) -# ─── Config ─────────────────────────────────────────────────────────────────── - PORT = int(os.getenv("PORT", "8330")) +APP_ENV = os.getenv("APP_ENV", os.getenv("NODE_ENV", "development")).lower() +DATABASE_URL = os.getenv("DPCO_ANALYTICS_DATABASE_URL") or os.getenv("DATABASE_URL") +DATABASE_POOL_MIN = int(os.getenv("DPCO_ANALYTICS_DB_POOL_MIN", "1")) +DATABASE_POOL_MAX = int(os.getenv("DPCO_ANALYTICS_DB_POOL_MAX", "10")) +SERVICE_TOKEN = os.getenv("DPCO_ANALYTICS_SERVICE_TOKEN", "") KAFKA_BROKERS = os.getenv("KAFKA_BROKERS", "localhost:9092") -KAFKA_ENABLED = os.getenv("KAFKA_ENABLED", "true").lower() == "true" +KAFKA_ENABLED = os.getenv("KAFKA_ENABLED", "false").lower() == "true" KAFKA_CONSUME_TOPICS = [ "ndsep.dpco.audit.events", "ndsep.dpco.registry.events", "ndsep.dpco.verification.events", ] KAFKA_PRODUCE_TOPIC = "ndsep.dpco.analytics.events" -FLUVIO_HTTP_URL = os.getenv("FLUVIO_HTTP_URL", "http://localhost:9003") -FLUVIO_ENABLED = os.getenv("FLUVIO_ENABLED", "true").lower() == "true" -LAKEHOUSE_CATALOG_URL = os.getenv("LAKEHOUSE_CATALOG_URL", "http://localhost:8181") -LAKEHOUSE_S3_ENDPOINT = os.getenv("LAKEHOUSE_S3_ENDPOINT", "http://localhost:9000") -LAKEHOUSE_S3_BUCKET = os.getenv("LAKEHOUSE_S3_BUCKET", "ndsep-lakehouse") -LAKEHOUSE_S3_ACCESS_KEY = os.getenv("LAKEHOUSE_S3_ACCESS_KEY", "minioadmin") -LAKEHOUSE_S3_SECRET_KEY = os.getenv("LAKEHOUSE_S3_SECRET_KEY", "minioadmin") -LAKEHOUSE_ENABLED = os.getenv("LAKEHOUSE_ENABLED", "true").lower() == "true" -REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") +FLUVIO_HTTP_URL = os.getenv("FLUVIO_HTTP_URL", "") +FLUVIO_ENABLED = os.getenv("FLUVIO_ENABLED", "false").lower() == "true" +LAKEHOUSE_S3_ENDPOINT = os.getenv("LAKEHOUSE_S3_ENDPOINT", "") +LAKEHOUSE_S3_BUCKET = os.getenv("LAKEHOUSE_S3_BUCKET", "") +LAKEHOUSE_S3_ACCESS_KEY = os.getenv("LAKEHOUSE_S3_ACCESS_KEY", "") +LAKEHOUSE_S3_SECRET_KEY = os.getenv("LAKEHOUSE_S3_SECRET_KEY", "") +LAKEHOUSE_ENABLED = os.getenv("LAKEHOUSE_ENABLED", "false").lower() == "true" +REDIS_URL = os.getenv("REDIS_URL", "") DAPR_HTTP_PORT = os.getenv("DAPR_HTTP_PORT", "3500") -DAPR_ENABLED = os.getenv("DAPR_ENABLED", "true").lower() == "true" +DAPR_ENABLED = os.getenv("DAPR_ENABLED", "false").lower() == "true" DAPR_APP_ID = os.getenv("DAPR_APP_ID", "dpco-analytics") -# ─── In-memory State ────────────────────────────────────────────────────────── +_metrics: dict[str, int] = {} +_start_time = time.time() +_middleware_status = {"kafka": False, "fluvio": False, "lakehouse": False, "redis": False, "dapr": False} -_lock = threading.Lock() -_events: List[Dict] = [] # all ingested events -_dpco_stats: Dict[str, Dict] = {} # per-DPCO aggregated stats -_trend_cache: Optional[Dict] = None -_trend_cache_ts: float = 0.0 -# Metrics -_metrics = collections.defaultdict(int) -_start_time = time.time() +def metric_increment(name: str, amount: int = 1) -> None: + _metrics[name] = _metrics.get(name, 0) + amount -# ─── Middleware Status ───────────────────────────────────────────────────────── -_middleware_status = { - "kafka": False, - "fluvio": False, - "lakehouse": False, - "redis": False, - "dapr": False, -} +def utc_now() -> datetime: + return datetime.now(timezone.utc) -# ─── Kafka Consumer ─────────────────────────────────────────────────────────── +def canonical_json(value: dict[str, Any]) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) -def _kafka_consumer_thread(): - if not KAFKA_ENABLED: - log.info("[Kafka] Consumer disabled") - return - try: - from kafka import KafkaConsumer - while True: - try: - consumer = KafkaConsumer( - *KAFKA_CONSUME_TOPICS, - bootstrap_servers=KAFKA_BROKERS.split(","), - group_id="dpco-analytics-service", - auto_offset_reset="latest", - enable_auto_commit=True, - value_deserializer=lambda m: json.loads(m.decode("utf-8")), - consumer_timeout_ms=5000, + +def parse_timestamp(value: Any) -> datetime: + if value is None: + return utc_now() + if not isinstance(value, str): + raise ValueError("event timestamp must be an ISO-8601 string") + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("event timestamp must include a UTC offset") + return parsed.astimezone(timezone.utc) + + +def finite_score(value: Any) -> Optional[float]: + if value is None or value == "": + return None + score = float(value) + if not 0 <= score <= 100: + raise ValueError("compliance_score must be between 0 and 100") + return score + + +def require_configuration() -> None: + if not DATABASE_URL: + raise RuntimeError("DPCO_ANALYTICS_DATABASE_URL or DATABASE_URL is required; analytics has no in-memory fallback") + if DATABASE_POOL_MIN < 1 or DATABASE_POOL_MAX < DATABASE_POOL_MIN: + raise RuntimeError("DPCO analytics database pool bounds are invalid") + if APP_ENV == "production" and len(SERVICE_TOKEN) < 32: + raise RuntimeError("DPCO_ANALYTICS_SERVICE_TOKEN must be at least 32 characters in production") + if APP_ENV == "production" and not DATABASE_URL.startswith("postgresql"): + raise RuntimeError("DPCO analytics production database URL must be PostgreSQL") + + +class DurableAnalyticsStore: + """PostgreSQL source of truth for DPCO analytics events and materialized stats.""" + + def __init__(self, dsn: str): + self.pool = ConnectionPool( + conninfo=dsn, + min_size=DATABASE_POOL_MIN, + max_size=DATABASE_POOL_MAX, + kwargs={"autocommit": False, "row_factory": dict_row}, + open=True, + ) + + @contextmanager + def connection(self) -> Iterator[Any]: + with self.pool.connection() as connection: + yield connection + + def close(self) -> None: + self.pool.close() + + def ping(self) -> None: + with self.connection() as connection: + connection.execute("SELECT 1") + + def ingest_event(self, event: dict[str, Any], source: str) -> bool: + if source not in {"api", "kafka", "dapr"}: + raise ValueError("event source is not permitted") + event_type = event.get("event_type") + if not isinstance(event_type, str) or not event_type or len(event_type) > 128: + raise ValueError("event_type is required and must be at most 128 characters") + dpco_id = event.get("dpco_id") or event.get("dpco_org_id") + if not isinstance(dpco_id, (str, int)) or not str(dpco_id).strip() or len(str(dpco_id)) > 128: + raise ValueError("dpco_id or dpco_org_id is required and must be at most 128 characters") + + occurred_at = parse_timestamp(event.get("timestamp")) + score = finite_score(event.get("compliance_score")) + to_stage = event.get("to_stage") + if to_stage is not None and (not isinstance(to_stage, str) or len(to_stage) > 128): + raise ValueError("to_stage must be a string of at most 128 characters") + + payload = dict(event) + payload_json = canonical_json(payload) + payload_sha256 = hashlib.sha256(payload_json.encode("utf-8")).hexdigest() + event_id = uuid.uuid5(uuid.NAMESPACE_URL, f"ndsep:dpco-analytics:{source}:{payload_sha256}") + audit_started = 1 if event_type == "dpco.audit.initiated" else 0 + audit_completed = 1 if event_type == "dpco.audit.stage_advanced" and to_stage == "car_filed" else 0 + statement_issued = 1 if event_type == "dpco.verification.issued" else 0 + actual_sla_breach = 1 if event_type == "dpco.sla.breached" or payload.get("sla_breached") is True else 0 + score_total = score if event_type == "dpco.audit.control_assessed" and score is not None else 0 + score_count = 1 if event_type == "dpco.audit.control_assessed" and score is not None else 0 + + with self.connection() as connection: + with connection.transaction(): + inserted = connection.execute( + """ + INSERT INTO dpco_analytics_events + (id, event_type, dpco_id, source, occurred_at, compliance_score, to_stage, payload, payload_sha256) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s) + ON CONFLICT (source, payload_sha256) DO NOTHING + RETURNING id + """, + (str(event_id), event_type, str(dpco_id), source, occurred_at, score, to_stage, payload_json, payload_sha256), + ).fetchone() + if inserted is None: + return False + connection.execute( + """ + INSERT INTO dpco_analytics_dpco_stats + (dpco_id, audits_initiated, audits_completed, statements_issued, score_total, score_count, sla_breaches, last_activity) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (dpco_id) DO UPDATE SET + audits_initiated = dpco_analytics_dpco_stats.audits_initiated + EXCLUDED.audits_initiated, + audits_completed = dpco_analytics_dpco_stats.audits_completed + EXCLUDED.audits_completed, + statements_issued = dpco_analytics_dpco_stats.statements_issued + EXCLUDED.statements_issued, + score_total = dpco_analytics_dpco_stats.score_total + EXCLUDED.score_total, + score_count = dpco_analytics_dpco_stats.score_count + EXCLUDED.score_count, + sla_breaches = dpco_analytics_dpco_stats.sla_breaches + EXCLUDED.sla_breaches, + last_activity = GREATEST(dpco_analytics_dpco_stats.last_activity, EXCLUDED.last_activity), + updated_at = NOW() + """, + (str(dpco_id), audit_started, audit_completed, statement_issued, score_total, score_count, actual_sla_breach, occurred_at), ) - _middleware_status["kafka"] = True - log.info("[Kafka] Consumer connected, topics: %s", KAFKA_CONSUME_TOPICS) - for msg in consumer: - try: - event = msg.value - _ingest_event(event, source="kafka") - _metrics["kafka_consumed"] += 1 - except Exception as e: - log.warning("[Kafka] Message processing error: %s", e) - except Exception as e: - _middleware_status["kafka"] = False - log.warning("[Kafka] Consumer connect failed (%s), retry in 15s", e) - time.sleep(15) - except ImportError: - log.warning("[Kafka] kafka-python not installed, consumer disabled") - - -def _kafka_produce(topic: str, event: Dict): + return True + + def event_count(self) -> int: + with self.connection() as connection: + row = connection.execute("SELECT COUNT(*)::int AS count FROM dpco_analytics_events").fetchone() + return int(row["count"]) + + def dpco_count(self) -> int: + with self.connection() as connection: + row = connection.execute("SELECT COUNT(*)::int AS count FROM dpco_analytics_dpco_stats").fetchone() + return int(row["count"]) + + def trends(self) -> dict[str, Any]: + with self.connection() as connection: + rows = connection.execute( + """ + SELECT + TO_CHAR(date_trunc('week', occurred_at), 'IYYY-"W"IW') AS week, + COUNT(*) FILTER (WHERE event_type = 'dpco.audit.initiated')::int AS audits, + COUNT(*) FILTER (WHERE event_type = 'dpco.verification.issued')::int AS statements, + COALESCE(ROUND(AVG(compliance_score) FILTER (WHERE event_type = 'dpco.audit.control_assessed'), 1), 0)::float AS avg_score + FROM dpco_analytics_events + WHERE occurred_at >= NOW() - INTERVAL '180 days' + GROUP BY date_trunc('week', occurred_at) + ORDER BY date_trunc('week', occurred_at) + """ + ).fetchall() + return {"weeks": rows, "total_weeks": len(rows), "generated_at": utc_now().isoformat(), "source": "postgresql"} + + def portfolio(self) -> list[dict[str, Any]]: + with self.connection() as connection: + return connection.execute( + """ + SELECT dpco_id, audits_initiated, audits_completed, statements_issued, + CASE WHEN score_count = 0 THEN 0 ELSE ROUND(score_total / score_count, 1) END::float AS avg_compliance_score, + sla_breaches, last_activity + FROM dpco_analytics_dpco_stats + ORDER BY last_activity DESC NULLS LAST, dpco_id + """ + ).fetchall() + + def sla(self) -> list[dict[str, Any]]: + with self.connection() as connection: + return connection.execute( + """ + SELECT dpco_id, + audits_initiated::int AS total_audits, + sla_breaches::int AS breached, + CASE WHEN audits_initiated = 0 THEN 0 + ELSE ROUND((sla_breaches::numeric / audits_initiated) * 100, 1) + END::float AS rate + FROM dpco_analytics_dpco_stats + ORDER BY dpco_id + """ + ).fetchall() + + def heatmap(self) -> list[dict[str, Any]]: + with self.connection() as connection: + return connection.execute( + """ + SELECT TO_CHAR(date_trunc('day', occurred_at), 'YYYY-MM-DD') AS date, COUNT(*)::int AS count + FROM dpco_analytics_events + WHERE occurred_at >= NOW() - INTERVAL '365 days' + GROUP BY date_trunc('day', occurred_at) + ORDER BY date_trunc('day', occurred_at) + """ + ).fetchall() + + +_store: Optional[DurableAnalyticsStore] = None +_store_lock = threading.Lock() + + +def get_store() -> DurableAnalyticsStore: + global _store + require_configuration() + with _store_lock: + if _store is None: + _store = DurableAnalyticsStore(DATABASE_URL or "") + return _store + + +def _redis_client() -> Any: + if not REDIS_URL: + raise RuntimeError("REDIS_URL is not configured") + import redis + return redis.from_url(REDIS_URL, socket_connect_timeout=2, socket_timeout=2) + + +def _redis_set(key: str, value: dict[str, Any], ttl: int = 300) -> None: try: - from kafka import KafkaProducer - p = KafkaProducer( - bootstrap_servers=KAFKA_BROKERS.split(","), - value_serializer=lambda v: json.dumps(v, default=str).encode("utf-8"), - ) - p.send(topic, event) - p.flush() - _metrics["kafka_produced"] += 1 - except Exception as e: - log.warning("[Kafka] Produce error: %s", e) + _redis_client().setex(key, ttl, canonical_json(value)) + _middleware_status["redis"] = True + metric_increment("redis_sets") + except Exception as error: + _middleware_status["redis"] = False + log.warning("[Redis] cache write failed: %s", error) -# ─── Fluvio Publisher ───────────────────────────────────────────────────────── +def _redis_get(key: str) -> Optional[dict[str, Any]]: + try: + value = _redis_client().get(key) + if value: + _middleware_status["redis"] = True + metric_increment("redis_hits") + return json.loads(value) + except Exception as error: + _middleware_status["redis"] = False + log.warning("[Redis] cache read failed: %s", error) + return None -def _fluvio_publish(topic: str, data: Dict): + +def _invalidate_caches() -> None: + try: + _redis_client().delete("dpco:analytics:trends", "dpco:analytics:portfolio") + _middleware_status["redis"] = True + except Exception as error: + _middleware_status["redis"] = False + log.warning("[Redis] cache invalidation failed: %s", error) + + +def _kafka_produce(topic: str, event: dict[str, Any]) -> bool: + if not KAFKA_ENABLED: + return False + try: + from kafka import KafkaProducer + producer = KafkaProducer( + bootstrap_servers=KAFKA_BROKERS.split(","), + value_serializer=lambda value: canonical_json(value).encode("utf-8"), + ) + producer.send(topic, event).get(timeout=10) + producer.flush(timeout=10) + producer.close(timeout=10) + metric_increment("kafka_produced") + return True + except Exception as error: + _middleware_status["kafka"] = False + log.warning("[Kafka] publish failed: %s", error) + return False + + +def _fluvio_publish(topic: str, data: dict[str, Any]) -> None: if not FLUVIO_ENABLED: return try: - resp = requests.post( - f"{FLUVIO_HTTP_URL}/topics/{topic}/produce", - json={"value": json.dumps(data, default=str)}, + response = requests.post( + f"{FLUVIO_HTTP_URL.rstrip('/')}/topics/{topic}/produce", + json={"value": canonical_json(data)}, timeout=3, ) - if resp.status_code < 300: - _middleware_status["fluvio"] = True - _metrics["fluvio_published"] += 1 - else: - _middleware_status["fluvio"] = False - except Exception as e: + response.raise_for_status() + _middleware_status["fluvio"] = True + metric_increment("fluvio_published") + except Exception as error: _middleware_status["fluvio"] = False - log.debug("[Fluvio] Publish error: %s", e) + log.warning("[Fluvio] publish failed: %s", error) -# ─── Lakehouse Ingestion ────────────────────────────────────────────────────── - -def _lakehouse_ingest(table: str, records: List[Dict]): - """Write records to Lakehouse via MinIO S3 as Parquet (snappy compressed).""" +def _lakehouse_ingest(table: str, records: list[dict[str, Any]]) -> None: if not LAKEHOUSE_ENABLED: return try: + import boto3 import pyarrow as pa import pyarrow.parquet as pq - import boto3 from botocore.client import Config - now = datetime.now(timezone.utc) - partition = now.strftime("%Y/%m/%d") - file_key = f"dpco/{table}/{partition}/{uuid.uuid4()}.parquet" - - # Build Arrow table - rows = [] - for r in records: - row = dict(r) - row.setdefault("id", str(uuid.uuid4())) - row["ingested_at"] = now.isoformat() - rows.append(row) - - # Flatten to string columns for generic schema - arrays = {k: [str(r.get(k, "")) for r in rows] for k in rows[0].keys()} - arrow_table = pa.table(arrays) - - buf = io.BytesIO() - pq.write_table(arrow_table, buf, compression="snappy") - buf.seek(0) - + if not all([LAKEHOUSE_S3_ENDPOINT, LAKEHOUSE_S3_BUCKET, LAKEHOUSE_S3_ACCESS_KEY, LAKEHOUSE_S3_SECRET_KEY]): + raise RuntimeError("lakehouse is enabled but S3 configuration is incomplete") + now = utc_now() + rows = [{**record, "ingested_at": now.isoformat()} for record in records] + columns = {key: [canonical_json(row[key]) if isinstance(row.get(key), (dict, list)) else str(row.get(key, "")) for row in rows] for key in rows[0]} + buffer = io.BytesIO() + pq.write_table(pa.table(columns), buffer, compression="snappy") s3 = boto3.client( "s3", endpoint_url=LAKEHOUSE_S3_ENDPOINT, @@ -209,349 +372,214 @@ def _lakehouse_ingest(table: str, records: List[Dict]): aws_secret_access_key=LAKEHOUSE_S3_SECRET_KEY, config=Config(signature_version="s3v4"), ) - s3.put_object(Bucket=LAKEHOUSE_S3_BUCKET, Key=file_key, Body=buf.getvalue()) + key = f"dpco/{table}/{now.strftime('%Y/%m/%d')}/{uuid.uuid4()}.parquet" + s3.put_object(Bucket=LAKEHOUSE_S3_BUCKET, Key=key, Body=buffer.getvalue()) _middleware_status["lakehouse"] = True - _metrics["lakehouse_ingested"] += len(records) - log.info("[Lakehouse] Ingested %d records to %s/%s", len(records), table, partition) - except ImportError: - log.debug("[Lakehouse] pyarrow/boto3 not installed, skipping") - except Exception as e: + metric_increment("lakehouse_ingested", len(records)) + except Exception as error: _middleware_status["lakehouse"] = False - log.warning("[Lakehouse] Ingest error: %s", e) - -# ─── Redis Cache ────────────────────────────────────────────────────────────── - + log.warning("[Lakehouse] ingest failed: %s", error) -def _redis_set(key: str, value: Dict, ttl: int = 300): - try: - import redis - r = redis.from_url(REDIS_URL) - r.setex(key, ttl, json.dumps(value, default=str)) - _middleware_status["redis"] = True - _metrics["redis_sets"] += 1 - except Exception as e: - _middleware_status["redis"] = False - log.debug("[Redis] Set error: %s", e) - -def _redis_get(key: str) -> Optional[Dict]: - try: - import redis - r = redis.from_url(REDIS_URL) - val = r.get(key) - if val: - _middleware_status["redis"] = True - _metrics["redis_hits"] += 1 - return json.loads(val) - except Exception as e: - log.debug("[Redis] Get error: %s", e) - return None - -# ─── Dapr Integration ───────────────────────────────────────────────────────── - - -def _dapr_publish(topic: str, data: Dict): +def _dapr_publish(topic: str, data: dict[str, Any]) -> None: if not DAPR_ENABLED: return try: - resp = requests.post( + response = requests.post( f"http://localhost:{DAPR_HTTP_PORT}/v1.0/publish/kafka-pubsub/{topic}", - json={"data": data}, + json=data, timeout=3, ) - if resp.status_code < 300: - _middleware_status["dapr"] = True - _metrics["dapr_published"] += 1 - except Exception as e: + response.raise_for_status() + _middleware_status["dapr"] = True + metric_increment("dapr_published") + except Exception as error: _middleware_status["dapr"] = False - log.debug("[Dapr] Publish error: %s", e) - -# ─── Event Processing ───────────────────────────────────────────────────────── - - -def _ingest_event(event: Dict, source: str = "api"): - """Process an incoming DPCO event and update aggregated stats.""" - event_type = event.get("event_type", "unknown") - dpco_id = event.get("dpco_id") or event.get("dpco_org_id", "unknown") - now = datetime.now(timezone.utc) - - with _lock: - _events.append({**event, "_source": source, "_ingested_at": now.isoformat()}) - # Keep last 10,000 events in memory - if len(_events) > 10000: - _events.pop(0) - - # Update per-DPCO stats - if dpco_id not in _dpco_stats: - _dpco_stats[dpco_id] = { - "dpco_id": dpco_id, - "audits_initiated": 0, - "audits_completed": 0, - "statements_issued": 0, - "avg_compliance_score": 0.0, - "sla_breaches": 0, - "last_activity": None, - } - stats = _dpco_stats[dpco_id] - stats["last_activity"] = now.isoformat() - - if event_type == "dpco.audit.initiated": - stats["audits_initiated"] += 1 - elif event_type == "dpco.audit.stage_advanced" and event.get("to_stage") == "car_filed": - stats["audits_completed"] += 1 - elif event_type == "dpco.verification.issued": - stats["statements_issued"] += 1 - elif event_type == "dpco.audit.control_assessed": - score = event.get("compliance_score", 0) - if score: - prev = stats["avg_compliance_score"] - n = stats["audits_initiated"] or 1 - stats["avg_compliance_score"] = (prev * (n - 1) + score) / n - - _metrics["events_ingested"] += 1 - - # Async lakehouse + Fluvio + Dapr (non-blocking) - threading.Thread( - target=_lakehouse_ingest, - args=("dpco_events", [event]), - daemon=True, - ).start() - _fluvio_publish("dpco.analytics.realtime", { - "event_type": event_type, "dpco_id": dpco_id, - "timestamp": now.isoformat(), "source": source, - }) - _dapr_publish("dpco.analytics.processed", { - "event_type": event_type, "dpco_id": dpco_id, "processed_at": now.isoformat(), - }) + log.warning("[Dapr] publish failed: %s", error) -# ─── Analytics Queries ──────────────────────────────────────────────────────── - - -def _compute_trends() -> Dict: - """Compute 6-month weekly compliance trend from in-memory events.""" - global _trend_cache, _trend_cache_ts - now = time.time() - if _trend_cache and now - _trend_cache_ts < 300: - return _trend_cache - - cutoff = datetime.now(timezone.utc) - timedelta(days=180) - weekly: Dict[str, Dict] = {} - - with _lock: - for ev in _events: - ts_str = ev.get("timestamp") or ev.get("_ingested_at", "") - try: - ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) - except Exception: - continue - if ts < cutoff: - continue - week = ts.strftime("%Y-W%W") - if week not in weekly: - weekly[week] = {"week": week, "audits": 0, "statements": 0, "avg_score": 0.0, "_scores": []} - et = ev.get("event_type", "") - if "audit.initiated" in et: - weekly[week]["audits"] += 1 - if "verification.issued" in et: - weekly[week]["statements"] += 1 - score = ev.get("compliance_score") - if score: - weekly[week]["_scores"].append(float(score)) - - result = [] - for w, d in sorted(weekly.items()): - scores = d.pop("_scores", []) - d["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0.0 - result.append(d) - - _trend_cache = {"weeks": result, "total_weeks": len(result), "generated_at": datetime.now(timezone.utc).isoformat()} - _trend_cache_ts = now - return _trend_cache - - -def _compute_portfolio() -> List[Dict]: - with _lock: - return list(_dpco_stats.values()) - - -def _compute_sla() -> List[Dict]: - """Compute SLA breach rates per DPCO (72h NDPC notification window).""" - sla_data: Dict[str, Dict] = {} - with _lock: - for ev in _events: - dpco_id = ev.get("dpco_id") or ev.get("dpco_org_id", "unknown") - if dpco_id not in sla_data: - sla_data[dpco_id] = {"dpco_id": dpco_id, "total": 0, "breached": 0, "rate": 0.0} - if "audit" in ev.get("event_type", ""): - sla_data[dpco_id]["total"] += 1 - # Simulate SLA breach: if compliance_score < 70, flag as breach - if float(ev.get("compliance_score", 100)) < 70: - sla_data[dpco_id]["breached"] += 1 - result = [] - for d in sla_data.values(): - t = d["total"] - d["rate"] = round(d["breached"] / t * 100, 1) if t > 0 else 0.0 - result.append(d) - return result - - -def _compute_heatmap() -> List[Dict]: - """Compute audit frequency heatmap (last 365 days, daily buckets).""" - cutoff = datetime.now(timezone.utc) - timedelta(days=365) - daily: Dict[str, int] = {} - with _lock: - for ev in _events: - ts_str = ev.get("timestamp") or ev.get("_ingested_at", "") - try: - ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) - except Exception: - continue - if ts < cutoff: - continue - day = ts.strftime("%Y-%m-%d") - daily[day] = daily.get(day, 0) + 1 - return [{"date": d, "count": c} for d, c in sorted(daily.items())] - -# ─── FastAPI App ────────────────────────────────────────────────────────────── - - -app = FastAPI(title="NDSEP DPCO Analytics Service", version="1.0.0") + +def _ingest_event(event: dict[str, Any], source: str) -> bool: + """Persist first. Downstream middleware cannot make domain analytics authoritative.""" + inserted = get_store().ingest_event(event, source) + if not inserted: + metric_increment("duplicate_events") + return False + metric_increment("events_ingested") + _invalidate_caches() + persisted = {**event, "source": source, "persisted_at": utc_now().isoformat()} + threading.Thread(target=_lakehouse_ingest, args=("dpco_events", [persisted]), daemon=True).start() + _fluvio_publish("dpco.analytics.realtime", persisted) + _dapr_publish("dpco.analytics.processed", persisted) + return True + + +def _kafka_consumer_thread() -> None: + if not KAFKA_ENABLED: + log.info("[Kafka] consumer disabled") + return + try: + from kafka import KafkaConsumer + except ImportError as error: + raise RuntimeError("kafka-python is required when KAFKA_ENABLED=true") from error + + while True: + consumer = None + try: + consumer = KafkaConsumer( + *KAFKA_CONSUME_TOPICS, + bootstrap_servers=KAFKA_BROKERS.split(","), + group_id="dpco-analytics-service", + auto_offset_reset="earliest", + enable_auto_commit=False, + value_deserializer=lambda message: json.loads(message.decode("utf-8")), + consumer_timeout_ms=5000, + ) + _middleware_status["kafka"] = True + for message in consumer: + _ingest_event(message.value, source="kafka") + consumer.commit() + metric_increment("kafka_consumed") + except Exception as error: + _middleware_status["kafka"] = False + log.warning("[Kafka] consumer error; offset was not committed for failed persistence: %s", error) + time.sleep(15) + finally: + if consumer is not None: + consumer.close() + + +class IngestRequest(BaseModel): + events: list[dict[str, Any]] = Field(min_length=1, max_length=500) + + +app = FastAPI(title="NDSEP DPCO Analytics Service", version="2.0.0") + + +def require_service_token(request: Request) -> None: + if not SERVICE_TOKEN: + if APP_ENV == "production": + raise HTTPException(status_code=503, detail="analytics service authentication is not configured") + return + supplied = request.headers.get("X-NDSEP-Service-Token", "") + if not hmac.compare_digest(supplied, SERVICE_TOKEN): + raise HTTPException(status_code=401, detail="invalid analytics service credential") @app.get("/health") def health(): - with _lock: - total_events = len(_events) - total_dpcos = len(_dpco_stats) - return { - "service": "dpco-analytics-service", - "status": "healthy", - "port": PORT, - "uptime_s": round(time.time() - _start_time, 1), - "middleware": _middleware_status, - "total_events_ingested": total_events, - "total_dpcos_tracked": total_dpcos, - "metrics": dict(_metrics), - "consume_topics": KAFKA_CONSUME_TOPICS, - "produce_topic": KAFKA_PRODUCE_TOPIC, - "lakehouse_bucket": LAKEHOUSE_S3_BUCKET, - "timestamp": datetime.now(timezone.utc).isoformat(), - } + try: + store = get_store() + store.ping() + return { + "service": "dpco-analytics-service", + "status": "healthy", + "persistence": "postgresql", + "port": PORT, + "uptime_s": round(time.time() - _start_time, 1), + "middleware": _middleware_status, + "total_events_ingested": store.event_count(), + "total_dpcos_tracked": store.dpco_count(), + "metrics": _metrics, + "consume_topics": KAFKA_CONSUME_TOPICS, + "produce_topic": KAFKA_PRODUCE_TOPIC, + "timestamp": utc_now().isoformat(), + } + except Exception as error: + return JSONResponse(status_code=503, content={"service": "dpco-analytics-service", "status": "not_ready", "persistence": "unavailable", "error": str(error)}) @app.get("/metrics") def get_metrics(): - return { - "metrics": dict(_metrics), - "middleware_status": _middleware_status, - "uptime_s": round(time.time() - _start_time, 1), - } + return {"metrics": _metrics, "middleware_status": _middleware_status, "uptime_s": round(time.time() - _start_time, 1)} @app.get("/api/dpco/analytics/trends") -def get_trends(): +def get_trends(request: Request): + require_service_token(request) cached = _redis_get("dpco:analytics:trends") if cached: return {**cached, "cache": "hit"} - result = _compute_trends() + result = get_store().trends() _redis_set("dpco:analytics:trends", result, ttl=300) return {**result, "cache": "miss"} @app.get("/api/dpco/analytics/portfolio") -def get_portfolio(): +def get_portfolio(request: Request): + require_service_token(request) cached = _redis_get("dpco:analytics:portfolio") if cached: - return {"dpcos": cached, "cache": "hit"} - data = _compute_portfolio() + return {"dpcos": cached, "total": len(cached), "cache": "hit", "source": "postgresql"} + data = get_store().portfolio() _redis_set("dpco:analytics:portfolio", data, ttl=120) - return {"dpcos": data, "total": len(data), "cache": "miss"} + return {"dpcos": data, "total": len(data), "cache": "miss", "source": "postgresql"} @app.get("/api/dpco/analytics/sla") -def get_sla(): - data = _compute_sla() - return {"sla_data": data, "total": len(data)} +def get_sla(request: Request): + require_service_token(request) + data = get_store().sla() + return {"sla_data": data, "total": len(data), "source": "postgresql"} @app.get("/api/dpco/analytics/heatmap") -def get_heatmap(): - data = _compute_heatmap() - return {"heatmap": data, "days": len(data)} - - -class IngestRequest(BaseModel): - events: List[Dict[str, Any]] +def get_heatmap(request: Request): + require_service_token(request) + data = get_store().heatmap() + return {"heatmap": data, "days": len(data), "source": "postgresql"} @app.post("/api/dpco/analytics/ingest") -def ingest_events(req: IngestRequest): - for ev in req.events: - _ingest_event(ev, source="api") - # Produce analytics event to Kafka +def ingest_events(request: Request, body: IngestRequest): + require_service_token(request) + inserted = 0 + duplicate = 0 + try: + for event in body.events: + if _ingest_event(event, source="api"): + inserted += 1 + else: + duplicate += 1 + except (ValueError, RuntimeError) as error: + raise HTTPException(status_code=422, detail=str(error)) from error + except Exception as error: + log.exception("[PostgreSQL] event persistence failed") + raise HTTPException(status_code=503, detail="durable analytics persistence is unavailable") from error _kafka_produce(KAFKA_PRODUCE_TOPIC, { "event_type": "dpco.analytics.batch_ingested", - "count": len(req.events), - "timestamp": datetime.now(timezone.utc).isoformat(), + "count": inserted, + "duplicates": duplicate, + "timestamp": utc_now().isoformat(), "source": "dpco-analytics-service", }) - return {"ok": True, "ingested": len(req.events)} - -# Dapr pub/sub subscription endpoint + return {"ok": True, "ingested": inserted, "duplicates": duplicate, "source": "postgresql"} @app.get("/dapr/subscribe") def dapr_subscribe(): - return [ - {"pubsubname": "kafka-pubsub", "topic": t, "route": f"/dapr/events/{t.replace('.', '/')}"} - for t in KAFKA_CONSUME_TOPICS - ] + return [{"pubsubname": "kafka-pubsub", "topic": topic, "route": f"/dapr/events/{topic.replace('.', '/')}"} for topic in KAFKA_CONSUME_TOPICS] @app.post("/dapr/events/{path:path}") async def dapr_event(path: str, request: Request): body = await request.json() data = body.get("data", body) - _ingest_event(data, source="dapr") - return {"status": "SUCCESS"} - -# ─── Background Threads ─────────────────────────────────────────────────────── - - -def _seed_demo_events(): - """Seed 90 days of demo DPCO events for analytics.""" - import random - dpcos = [f"dpco-{i:03d}" for i in range(1, 21)] - audit_types = ["annual_compliance", "special_purpose", "data_breach_response"] - now = datetime.now(timezone.utc) - for day_offset in range(90): - ts = now - timedelta(days=90 - day_offset) - for _ in range(random.randint(1, 5)): - dpco_id = random.choice(dpcos) - _ingest_event({ - "event_type": random.choice(["dpco.audit.initiated", "dpco.verification.issued", "dpco.audit.stage_advanced"]), - "dpco_id": dpco_id, - "org_id": f"org-{random.randint(1, 100):03d}", - "audit_type": random.choice(audit_types), - "compliance_score": round(random.uniform(55, 98), 1), - "timestamp": ts.isoformat(), - "to_stage": random.choice(["fieldwork", "car_filed", "report_issued"]), - }, source="seed") - log.info("[Seed] Seeded 90 days of demo DPCO analytics events") - -# ─── Main ───────────────────────────────────────────────────────────────────── + if not isinstance(data, dict): + raise HTTPException(status_code=422, detail="Dapr event data must be an object") + try: + _ingest_event(data, source="dapr") + return {"status": "SUCCESS"} + except (ValueError, RuntimeError) as error: + raise HTTPException(status_code=422, detail=str(error)) from error + except Exception as error: + log.exception("[Dapr] durable event persistence failed") + raise HTTPException(status_code=503, detail="durable analytics persistence is unavailable") from error if __name__ == "__main__": - log.info("DPCO Analytics Service starting on port %d", PORT) - log.info("Middleware: Kafka=%s Fluvio=%s Lakehouse=%s Redis=%s Dapr=%s", - KAFKA_ENABLED, FLUVIO_ENABLED, LAKEHOUSE_ENABLED, True, DAPR_ENABLED) - log.info("Consume topics: %s", KAFKA_CONSUME_TOPICS) - log.info("Lakehouse bucket: %s endpoint: %s", LAKEHOUSE_S3_BUCKET, LAKEHOUSE_S3_ENDPOINT) - - # Seed demo data - threading.Thread(target=_seed_demo_events, daemon=True).start() - # Start Kafka consumer - threading.Thread(target=_kafka_consumer_thread, daemon=True).start() - + require_configuration() + get_store().ping() + log.info("DPCO analytics service starting with PostgreSQL source of truth on port %d", PORT) + if KAFKA_ENABLED: + threading.Thread(target=_kafka_consumer_thread, daemon=True).start() uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info") diff --git a/orchestration/tests/test_dpco_analytics_durable.py b/orchestration/tests/test_dpco_analytics_durable.py new file mode 100644 index 00000000..15d19d5f --- /dev/null +++ b/orchestration/tests/test_dpco_analytics_durable.py @@ -0,0 +1,125 @@ +"""Contract tests for durable DPCO analytics persistence. + +The test double implements the narrow psycopg connection protocol used by the +service. It validates SQL parameterization and transaction sequencing without +requiring a live database in source CI; protected staging separately validates +the actual PostgreSQL migration and runtime connectivity. +""" +from __future__ import annotations + +import importlib.util +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import pytest + +SERVICE_PATH = Path(__file__).parents[1] / "python" / "dpco_analytics" / "service.py" +SPEC = importlib.util.spec_from_file_location("dpco_analytics_service_under_test", SERVICE_PATH) +assert SPEC and SPEC.loader +service = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(service) + + +class Result: + def __init__(self, row: dict[str, Any] | None = None, rows: list[dict[str, Any]] | None = None): + self.row = row + self.rows = rows or [] + + def fetchone(self): + return self.row + + def fetchall(self): + return self.rows + + +class FakeConnection: + def __init__(self): + self.statements: list[tuple[str, tuple[Any, ...]]] = [] + self.transactions = 0 + self.duplicate = False + + @contextmanager + def transaction(self): + self.transactions += 1 + yield self + + def execute(self, sql: str, params: tuple[Any, ...] = ()): # psycopg-compatible narrow protocol + self.statements.append((" ".join(sql.split()), params)) + if "INSERT INTO dpco_analytics_events" in sql: + return Result(None if self.duplicate else {"id": "durable-event"}) + if "COUNT(*)" in sql: + return Result({"count": 0}) + return Result() + + +class FakePool: + def __init__(self): + self.connection_object = FakeConnection() + self.closed = False + + @contextmanager + def connection(self): + yield self.connection_object + + def close(self): + self.closed = True + + +def build_store(monkeypatch: pytest.MonkeyPatch) -> tuple[Any, FakePool]: + fake_pool = FakePool() + monkeypatch.setattr(service, "ConnectionPool", lambda **_kwargs: fake_pool) + return service.DurableAnalyticsStore("postgresql://durable-test"), fake_pool + + +def valid_event(**overrides: Any) -> dict[str, Any]: + event = { + "event_type": "dpco.audit.control_assessed", + "dpco_id": "dpco-test-001", + "timestamp": "2026-09-01T12:00:00+00:00", + "compliance_score": 88.5, + } + event.update(overrides) + return event + + +def test_event_is_persisted_before_aggregate_and_duplicate_does_not_increment(monkeypatch: pytest.MonkeyPatch): + store, fake_pool = build_store(monkeypatch) + assert store.ingest_event(valid_event(), "api") is True + statements = fake_pool.connection_object.statements + assert fake_pool.connection_object.transactions == 1 + assert len(statements) == 2 + assert "INSERT INTO dpco_analytics_events" in statements[0][0] + assert "ON CONFLICT (source, payload_sha256) DO NOTHING" in statements[0][0] + assert "INSERT INTO dpco_analytics_dpco_stats" in statements[1][0] + assert statements[0][1][3] == "api" + assert statements[0][1][5] == 88.5 + + fake_pool.connection_object.duplicate = True + assert store.ingest_event(valid_event(), "api") is False + assert fake_pool.connection_object.transactions == 2 + assert len(fake_pool.connection_object.statements) == 3 + assert "INSERT INTO dpco_analytics_events" in fake_pool.connection_object.statements[-1][0] + + +def test_invalid_event_is_rejected_before_any_database_statement(monkeypatch: pytest.MonkeyPatch): + store, fake_pool = build_store(monkeypatch) + with pytest.raises(ValueError, match="UTC offset"): + store.ingest_event(valid_event(timestamp="2026-09-01T12:00:00"), "api") + with pytest.raises(ValueError, match="between 0 and 100"): + store.ingest_event(valid_event(compliance_score=101), "api") + with pytest.raises(ValueError, match="source is not permitted"): + store.ingest_event(valid_event(), "untrusted") + assert fake_pool.connection_object.statements == [] + + +def test_production_configuration_has_no_memory_or_credential_fallback(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(service, "APP_ENV", "production") + monkeypatch.setattr(service, "DATABASE_URL", None) + monkeypatch.setattr(service, "SERVICE_TOKEN", "") + with pytest.raises(RuntimeError, match="DATABASE_URL"): + service.require_configuration() + + monkeypatch.setattr(service, "DATABASE_URL", "postgresql://analytics.example.internal/ndsep") + with pytest.raises(RuntimeError, match="SERVICE_TOKEN"): + service.require_configuration() diff --git a/orchestration/tests/test_dpco_analytics_postgres_integration.py b/orchestration/tests/test_dpco_analytics_postgres_integration.py new file mode 100644 index 00000000..7a4344aa --- /dev/null +++ b/orchestration/tests/test_dpco_analytics_postgres_integration.py @@ -0,0 +1,84 @@ +"""Real PostgreSQL integration coverage for durable DPCO analytics. + +This test is intentionally skipped unless DPCO_ANALYTICS_DATABASE_URL identifies a +CI-owned disposable database. It never falls back to SQLite, memory, a fixture +file, staging, or production. +""" +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path + +import psycopg +import pytest +from psycopg.rows import dict_row + +ROOT = Path(__file__).parents[2] +DSN = os.environ.get("DPCO_ANALYTICS_DATABASE_URL") +pytestmark = pytest.mark.skipif(not DSN, reason="DPCO_ANALYTICS_DATABASE_URL is required for real PostgreSQL integration coverage") + + +def load_service(): + source = ROOT / "orchestration" / "python" / "dpco_analytics" / "service.py" + spec = importlib.util.spec_from_file_location("dpco_analytics_real_postgres", source) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def apply_active_analytics_migration() -> None: + migration = ROOT / "drizzle" / "0036_dpco_analytics_durable_events.sql" + with psycopg.connect(DSN, autocommit=True) as connection: + connection.execute(migration.read_text(encoding="utf-8")) + connection.execute("TRUNCATE TABLE dpco_analytics_events, dpco_analytics_dpco_stats RESTART IDENTITY") + + +def event(event_type: str, **extra): + payload = { + "event_type": event_type, + "dpco_id": "dpco-ci-postgres-001", + "timestamp": "2026-09-01T12:00:00+00:00", + } + payload.update(extra) + return payload + + +def test_durable_analytics_persists_idempotent_events_and_postgresql_aggregates(): + apply_active_analytics_migration() + service = load_service() + store = service.DurableAnalyticsStore(DSN) + try: + assert store.ingest_event(event("dpco.audit.initiated"), "api") is True + assert store.ingest_event(event("dpco.audit.control_assessed", compliance_score=91.5), "api") is True + assert store.ingest_event(event("dpco.sla.breached"), "api") is True + assert store.ingest_event(event("dpco.audit.control_assessed", compliance_score=91.5), "api") is False + + with psycopg.connect(DSN, row_factory=dict_row) as connection: + event_count = connection.execute("SELECT COUNT(*)::int AS count FROM dpco_analytics_events").fetchone() + stats = connection.execute( + """ + SELECT audits_initiated, audits_completed, statements_issued, + score_total, score_count, sla_breaches + FROM dpco_analytics_dpco_stats WHERE dpco_id = %s + """, + ("dpco-ci-postgres-001",), + ).fetchone() + outbox = connection.execute( + "SELECT to_regclass('public.domain_event_outbox') IS NOT NULL AS exists" + ).fetchone() + + assert event_count == {"count": 3} + assert stats == { + "audits_initiated": 1, + "audits_completed": 0, + "statements_issued": 0, + "score_total": 91.5, + "score_count": 1, + "sla_breaches": 1, + } + assert outbox == {"exists": True} + assert store.trends()["source"] == "postgresql" + finally: + store.close() diff --git a/package.json b/package.json index b51f4020..6dcbce5b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "db:seed": "node scripts/seed.mjs", "db:seed:force": "FORCE_SEED=1 node scripts/seed.mjs", "seed:all": "node scripts/seed-comprehensive.mjs", + "seed:remaining": "node scripts/seed-remaining-synthetic-fixtures.mjs", "seed:all:force": "FORCE_SEED=1 node scripts/seed-comprehensive.mjs", + "seed:verify": "node scripts/verify-synthetic-seed-coverage.mjs", + "seed:verify:scenario": "node scripts/verify-synthetic-fixture-scenario.mjs", "e2e": "playwright test", "e2e:release-smoke": "playwright test e2e/release-smoke.spec.ts", "e2e:release-smoke:chromium": "playwright test e2e/release-smoke.spec.ts --project=chromium --workers=1 --timeout=30000 --global-timeout=300000", @@ -75,7 +78,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "@temporalio/client": "^1.15.0", - "@temporalio/worker": "^1.15.0", "@temporalio/workflow": "^1.15.0", "@trpc/client": "^11.16.0", "@trpc/react-query": "^11.6.0", @@ -172,7 +174,7 @@ "@types/supertest": "^7.2.0", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.0.4", - "@vitest/coverage-v8": "3", + "@vitest/coverage-v8": "3.2.6", "add": "^2.0.6", "autoprefixer": "^10.4.21", "cors": "^2.8.6", @@ -196,140 +198,5 @@ "vite-plugin-pwa": "^1.2.0", "vitest": "3.2.6" }, - "packageManager": "pnpm@10.34.4", - "pnpm": { - "patchedDependencies": { - "wouter@3.7.1": "patches/wouter@3.7.1.patch" - }, - "overrides": { - "tailwindcss>nanoid": "3.3.7", - "fast-xml-parser": ">=5.7.0", - "esbuild@<=0.24.2": ">=0.25.0", - "tar@=7.5.1": ">=7.5.2", - "pnpm@>=10.0.0 <10.26.0": ">=10.26.0", - "pnpm@<10.26.0": ">=10.26.0", - "pnpm@>=6.25.0 <10.27.0": ">=10.27.0", - "lodash-es@>=4.0.0 <=4.17.22": ">=4.17.23", - "lodash@>=4.0.0 <=4.17.22": ">=4.17.23", - "pnpm@<10.28.2": ">=10.28.2", - "pnpm@<10.28.1": ">=10.28.1", - "tar@<7.5.7": ">=7.5.7", - "mdast-util-to-hast@>=13.0.0 <13.2.1": ">=13.2.1", - "qs@>=6.7.0 <=6.14.1": ">=6.14.2", - "tar@<=7.5.2": ">=7.5.3", - "tar@<7.5.8": ">=7.5.8", - "rollup@>=4.0.0 <4.59.0": ">=4.59.0", - "serialize-javascript@<=7.0.2": ">=7.0.3", - "qs@<6.14.1": ">=6.14.1", - "tar@<=7.5.9": ">=7.5.10", - "tar@<=7.5.10": ">=7.5.11", - "tar@<=7.5.3": ">=7.5.4", - "socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6", - "picomatch@>=4.0.0 <4.0.4": ">=4.0.4", - "yaml@>=2.0.0 <2.8.3": ">=2.8.3", - "serialize-javascript@<7.0.5": ">=7.0.5", - "lodash-es@>=4.0.0 <=4.17.23": ">=4.18.0", - "lodash@>=4.0.0 <=4.17.23": ">=4.18.0", - "lodash-es@<=4.17.23": ">=4.18.0", - "lodash@<=4.17.23": ">=4.18.0", - "vite@<=6.4.1": ">=6.4.2", - "drizzle-orm@<0.45.2": ">=0.45.2", - "nodemailer@<=8.0.4": ">=8.0.5", - "follow-redirects@<=1.15.11": ">=1.16.0", - "dompurify@<=3.3.3": ">=3.4.0", - "qs": ">=6.14.2", - "lodash": ">=4.17.21", - "lodash-es": ">=4.17.21", - "serialize-javascript": ">=6.0.2", - "yaml": ">=2.7.1", - "picomatch": ">=4.0.2", - "follow-redirects": ">=1.15.6", - "dompurify": ">=3.2.4", - "protobufjs@<=7.5.5": ">=7.5.6", - "protobufjs@>=8.0.0 <=8.0.1": ">=8.0.2", - "fast-xml-builder@<=1.1.6": ">=1.1.7", - "fast-xml-parser@<=5.7.1": ">=5.7.2", - "prom-client@<=15.1.3": ">=15.1.4", - "postcss@<8.5.10": ">=8.5.10", - "uuid@>=11.0.0 <11.1.1": ">=11.1.1", - "ip-address@<10.1.1": ">=10.1.1", - "mermaid@<11.15.0": ">=11.15.0", - "ws@>=8.0.0 <8.20.1": ">=8.20.1", - "uuid@<11.1.1": ">=11.1.1", - "qs@>=6.11.1 <=6.15.1": ">=6.15.2", - "brace-expansion@>=5.0.0 <5.0.6": ">=5.0.6", - "@grpc/grpc-js@>=1.14.0 <1.14.4": ">=1.14.4", - "tmp@<0.2.6": ">=0.2.6", - "esbuild@>=0.27.3 <0.28.1": ">=0.28.1", - "form-data@>=4.0.0 <4.0.6": ">=4.0.6", - "tar@<=7.5.15": ">=7.5.16", - "vite@>=7.0.0 <=7.3.4": ">=7.3.5 <8", - "nodemailer@<=8.0.8": ">=8.0.9", - "nodemailer@<=8.0.7": ">=8.0.8", - "dompurify@<=3.4.5": ">=3.4.6", - "dompurify@<=3.4.6": ">=3.4.7", - "multer@>=1.0.0 <2.2.0": ">=2.2.0", - "multer@>=2.0.0-alpha.1 <2.2.0": ">=2.2.0", - "nodemailer@<=9.0.0": ">=9.0.1", - "pnpm@<10.33.4": ">=10.33.4", - "pnpm@<10.34.0": ">=10.34.0", - "pnpm@<10.34.1": ">=10.34.1", - "pnpm@<10.34.2": ">=10.34.2", - "pnpm@<10.34.4": ">=10.34.4", - "ws@>=8.0.0 <8.21.0": ">=8.21.0", - "protobufjs@>=8.2.0 <=8.4.2": ">=8.5.0", - "protobufjs@>=8.0.0 <=8.4.0": ">=8.4.1", - "protobufjs@>=8.0.0 <=8.5.0": ">=8.6.0", - "@babel/core@<=7.29.0": ">=7.29.6 <8", - "axios@>=1.0.0 <1.18.0": ">=1.18.0", - "brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2", - "brace-expansion@<1.1.16": ">=1.1.16", - "brace-expansion@>=3.0.0 <5.0.7": ">=5.0.7", - "tar@<=7.5.17": ">=7.5.18", - "tar@<=7.5.18": ">=7.5.19", - "tar@<=7.5.16": ">=7.5.17", - "engine.io@>=4.1.0 <6.6.7": ">=6.6.7", - "axios@>=1.7.0 <1.18.0": ">=1.18.0", - "axios@>=1.15.0 <1.18.0": ">=1.18.0", - "protobufjs@>=8.2.0 <=8.6.4": ">=8.6.5", - "protobufjs@>=8.0.0 <=8.6.5": ">=8.6.6", - "axios@>=1.15.2 <1.18.0": ">=1.18.0", - "axios@>=1.15.1 <1.18.0": ">=1.18.0", - "axios@>=1.13.0 <1.18.0": ">=1.18.0", - "body-parser@<1.20.6": ">=1.20.6", - "@opentelemetry/propagator-jaeger@<2.9.0": ">=2.9.0", - "dompurify@<=3.4.11": ">=3.4.12", - "fast-uri@>=3.0.0 <=3.1.3": ">=3.1.4", - "dompurify@<=3.4.10": ">=3.4.11", - "dompurify@<3.4.9": ">=3.4.9", - "dompurify@>=3.0.0 <=3.4.7": ">=3.4.8", - "dompurify@<3.4.7": ">=3.4.7", - "brace-expansion@<1.1.17": ">=1.1.17", - "brace-expansion@>=2.0.0 <2.1.3": ">=2.1.3", - "brace-expansion@>=4.0.0 <5.0.8": ">=5.0.8", - "postcss@<=8.5.22": ">=8.5.23", - "socket.io-parser@>=4.0.0 <4.2.7": ">=4.2.7", - "fast-uri@>=3.0.0 <3.1.5": ">=3.1.5", - "ip-address@<=10.3.0": ">=10.3.1", - "ip-address@>=10.1.1 <=10.2.1": ">=10.2.2", - "ip-address@>=10.1.1 <=10.2.0": ">=10.2.1", - "brace-expansion@>=4.0.0 <5.0.9": ">=5.0.9", - "brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4", - "brace-expansion@<1.1.18": ">=1.1.18", - "mermaid@>=11.0.0-alpha.1 <11.16.1": ">=11.16.1", - "mermaid@>=11.5.0 <11.16.1": ">=11.16.1", - "mermaid@>=11.6.0 <11.16.1": ">=11.16.1", - "dompurify@<=3.4.12": ">=3.4.13", - "nanoid@>=4.0.0 <5.1.16": ">=5.1.16", - "nanoid@<3.3.16": ">=3.3.16", - "nanoid@<3.3.18": ">=3.3.18", - "postcss@<=8.5.17": ">=8.5.18", - "vitest@<3.2.6": ">=3.2.6 <4", - "tar@<=7.5.20": ">=7.5.21", - "@opentelemetry/core@<2.8.0": ">=2.8.0", - "fast-uri@>=3.0.0 <3.1.3": ">=3.1.3", - "brace-expansion": ">=5.0.9", - "protobufjs": ">=8.6.6" - } - } + "packageManager": "pnpm@10.34.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fad927ec..4c9d030c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: + browserslist: 4.28.7 + mysql2: 3.22.0 + minimatch@9>brace-expansion: 2.1.4 tailwindcss>nanoid: 3.3.7 fast-xml-parser: '>=5.7.0' esbuild@<=0.24.2: '>=0.25.0' @@ -43,7 +46,7 @@ overrides: qs: '>=6.14.2' lodash: '>=4.17.21' lodash-es: '>=4.17.21' - serialize-javascript: '>=6.0.2' + serialize-javascript: 7.0.5 yaml: '>=2.7.1' picomatch: '>=4.0.2' follow-redirects: '>=1.15.6' @@ -131,7 +134,6 @@ overrides: tar@<=7.5.20: '>=7.5.21' '@opentelemetry/core@<2.8.0': '>=2.8.0' fast-uri@>=3.0.0 <3.1.3: '>=3.1.3' - brace-expansion: '>=5.0.9' protobufjs: '>=8.6.6' patchedDependencies: @@ -166,7 +168,7 @@ importers: version: 1.9.1 '@opentelemetry/auto-instrumentations-node': specifier: ^0.75.0 - version: 0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) + version: 0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) '@opentelemetry/exporter-trace-otlp-http': specifier: ^0.217.0 version: 0.217.0(@opentelemetry/api@1.9.1) @@ -275,9 +277,6 @@ importers: '@temporalio/client': specifier: ^1.15.0 version: 1.15.0 - '@temporalio/worker': - specifier: ^1.15.0 - version: 1.22.0(@swc/helpers@0.5.19)(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26) '@temporalio/workflow': specifier: ^1.15.0 version: 1.22.0 @@ -352,7 +351,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.18.0)(mysql2@3.19.0(@types/node@24.7.0))(pg@8.20.0) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.18.0)(pg@8.20.0) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.1) @@ -409,7 +408,7 @@ importers: version: 2.3.0 nanoid: specifier: '>=5.1.16' - version: 6.0.1 + version: 5.1.16 next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -418,7 +417,7 @@ importers: version: 1.4.0 nodemailer: specifier: '>=9.0.1' - version: 9.0.6 + version: 9.1.0 pdfkit: specifier: ^0.17.2 version: 0.17.2 @@ -562,8 +561,8 @@ importers: specifier: ^5.0.4 version: 5.0.4(vite@7.3.5(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.33.0)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.3)) '@vitest/coverage-v8': - specifier: '3' - version: 3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.33.0)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.3)) + specifier: 3.2.6 + version: 3.2.6(vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.33.0)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.3)) add: specifier: ^2.0.6 version: 2.0.6 @@ -593,7 +592,7 @@ importers: version: 9.1.7 pnpm: specifier: '>=10.34.4' - version: 11.24.0 + version: 10.34.5 postcss: specifier: '>=8.5.18' version: 8.5.26 @@ -960,11 +959,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} @@ -1894,126 +1888,6 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@jsonjoy.com/base64@1.1.2': - resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/base64@17.67.0': - resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/buffers@1.2.1': - resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/buffers@17.67.0': - resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/codegen@1.0.0': - resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/codegen@17.67.0': - resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-core@4.68.1': - resolution: {integrity: sha512-V5oZ4Gt9WJKyQef0n9cAd0N9qjSkIBm3E4MYsgNIWBk5aINCDPKxMPo1i29rBxqiT4Ixf1epklqV9VJMKIxwlw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-fsa@4.68.1': - resolution: {integrity: sha512-HCG72UioncuO7Gw09XNVG+S85e3cq2hrUC/mexBrsWsa3mI7eePkkqWie3uVYbtsb64OR9YGQs5SqaufDRYBcg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node-builtins@4.68.1': - resolution: {integrity: sha512-HK1BTksysokNZxNspqDH0yPaqN9YgR/AYIlYiIaU2Ys4BOk5CdybI7r6BgiZuiiPiV8n4sK/kZdice7Znpy2Kw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node-to-fsa@4.68.1': - resolution: {integrity: sha512-lpKmU4X9e/oh8GIuAI7EXaS5QiLNM3KD15CkdhfS6PYmrGvoJqKQcyEfnLgnnaGslh/PFUMYSIZBCf2ejJGw8g==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node-utils@4.68.1': - resolution: {integrity: sha512-/GxfW1DWm9SCdkfbvqevLO/P5duobQfmKkHXxdMIDbcZMQeAgooAstIfZhkXpATzq9QbCQsnoWFM/dGHdZfndw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node@4.68.1': - resolution: {integrity: sha512-R5D9mWtqdURzcOWj1vdXr3APCwX0xchtFT+kmW7fXLNDifWdDrnh26jSID8pdnUfFBxTyfHtFtTL/NWKzIH7kQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-print@4.68.1': - resolution: {integrity: sha512-oGeZOGPYKK9v1CgeVeEDsLomH1lCnslSpqUN5GmPzrmAVGQlsmsdcXNA2O4lV8Y4xkuSuynx2ITBkUHJVaTbow==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-snapshot@4.68.1': - resolution: {integrity: sha512-XZfP0FDZN32bbc4t2bZN2qRrYHg5AktJnzk22HRoKGK4BprrbNRH2k5ceSNS/kupKYcofCs+O841+xaAbjnxwQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pack@1.21.0': - resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pack@17.67.0': - resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pointer@1.0.2': - resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pointer@17.67.0': - resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/util@1.9.0': - resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/util@17.67.0': - resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} @@ -2067,8 +1941,8 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} '@opentelemetry/api-logs@0.214.0': resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} @@ -2107,6 +1981,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-grpc@0.217.0': resolution: {integrity: sha512-vC5S0Dc+noxD86CVtNu1+awCHPA5Kewi1Sg23ps+9lh4YifwsKXh3pe4XTNEKtUJiAcjpJ5dqStGakLbrSE+YQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -2467,8 +2347,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.10.0': - resolution: {integrity: sha512-yw/IX8DL470dSMZJoE82ScfYGp7JWZ/G8kFJo35ZILUVTB2jFPTOaioN+8s09pH0RHsWNhweVZb+ZnjJJpCChg==} + '@opentelemetry/propagator-jaeger@2.11.0': + resolution: {integrity: sha512-RUOqgoIqXOPQTOhpBrs07Y8sBlxVoLY5Fka6ueZ0prW+k6fWQsba/nHDY4gm+gmfuwtRqbX5Y9YKhDvktjbIqQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3788,102 +3668,9 @@ packages: '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} - '@swc/core-darwin-arm64@1.15.47': - resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-x64@1.15.47': - resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-linux-arm-gnueabihf@1.15.47': - resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.15.47': - resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-arm64-musl@1.15.47': - resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@swc/core-linux-ppc64-gnu@1.15.47': - resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} - engines: {node: '>=10'} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-s390x-gnu@1.15.47': - resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} - engines: {node: '>=10'} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@swc/core-linux-x64-gnu@1.15.47': - resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-x64-musl@1.15.47': - resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@swc/core-win32-arm64-msvc@1.15.47': - resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] - - '@swc/core-win32-ia32-msvc@1.15.47': - resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} - engines: {node: '>=10'} - cpu: [ia32] - os: [win32] - - '@swc/core-win32-x64-msvc@1.15.47': - resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] - - '@swc/core@1.15.47': - resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} - engines: {node: '>=10'} - peerDependencies: - '@swc/helpers': '>=0.5.17' - peerDependenciesMeta: - '@swc/helpers': - optional: true - - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/helpers@0.5.19': resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} - '@swc/types@0.1.28': - resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} - '@tailwindcss/node@4.1.14': resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} @@ -3991,18 +3778,10 @@ packages: peerDependencies: react: ^18 || ^19 - '@temporalio/activity@1.22.0': - resolution: {integrity: sha512-hrPeELIMloFWVrwmDfG1lh3uFNkaYp1ZHpcpWmJniTeSyxqCqLdil4lBOv11FByb6HeKxE6rsvOOUohulyVhRg==} - engines: {node: '>= 20.3.0'} - '@temporalio/client@1.15.0': resolution: {integrity: sha512-SxTGqRIa2+Vy4P9+06ZpUf4u7ZZmOXfx/kr9XvNqAApLxTMKjTQIg5OH5Wt4JLUtIR7dFkuHIyhewdRyG+hSsQ==} engines: {node: '>= 20.0.0'} - '@temporalio/client@1.22.0': - resolution: {integrity: sha512-b02ty0uDly6/ZRKlbavs5GjXVTry/GB4tkq0v1NH4H7x+nTo/OK0u5Ms8Nni8D+aar3xff9lzIpXNwu/Dp3P3w==} - engines: {node: '>= 20.3.0'} - '@temporalio/common@1.15.0': resolution: {integrity: sha512-tBfC3fdOExsNoS5krkMUXnaMtCRKj05Jts4+TH+cgHpbys68nslFvUQLqwPIw2x6155Divb9MF219a/75itbTg==} engines: {node: '>= 20.0.0'} @@ -4011,14 +3790,6 @@ packages: resolution: {integrity: sha512-1NpQpo/y6XU1ULbo/bXgBEhl6qQnOeB79NrsSJdjI38/hfXPom2IYGJcIxOFfsQICQ0zePEKzB9Yyo31KNpIxA==} engines: {node: '>= 20.3.0'} - '@temporalio/core-bridge@1.22.0': - resolution: {integrity: sha512-mJ5agqtfW+GBPYl+ivYLrBpKJ3oFOw8ogcoizNrxYCzwJeprGFiEgHDb/jzyvECR4HAy5s9zQFJYNyTruXXFMw==} - engines: {node: '>= 20.3.0'} - - '@temporalio/nexus@1.22.0': - resolution: {integrity: sha512-4S1ZZdSNjrO9IUyhWxweIidvNn1X/ihEl+P2fOn/Ky38YRx9G+8wtV6g6lqG8zUrtEv//uqFw+HsRMziE2y4tg==} - engines: {node: '>= 20.3.0'} - '@temporalio/proto@1.15.0': resolution: {integrity: sha512-Awy4Fjzyba7Pg/CVZjjQ3x2CWkDL1qELyTZWcLlyjXq8bX694JVfBsmiMmF6tHn5/ySOIxDTcc0MSScZ0oKX5A==} engines: {node: '>= 20.0.0'} @@ -4027,10 +3798,6 @@ packages: resolution: {integrity: sha512-X7NVa0Z6HK3l9irZR48FKwZ9w9YXetCdF2z2KCIRr0W7Kul64Wt9o5hwvSXShAtrZuCEEHH8WO/ffHTXxJieLg==} engines: {node: '>= 20.3.0'} - '@temporalio/worker@1.22.0': - resolution: {integrity: sha512-axRHhUsovFlqDtXZxUnhRLV7WmC6qOMUCij13lw4glT5yRH62k8wIZqcT3naEfvLSzoDxjt2xM7me3VvTRSaow==} - engines: {node: '>= 20.3.0'} - '@temporalio/workflow@1.22.0': resolution: {integrity: sha512-31Ax529+TvfH2RCexOhiCyM3lARJJoVzrnRUS1gX+jl+mka+pRRnEUx4B572mZz0oOlCCxbiztHZF72wwwWZDA==} engines: {node: '>= 20.3.0'} @@ -4206,9 +3973,6 @@ packages: '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4412,11 +4176,11 @@ packages: peerDependencies: vite: '>=6.4.2' - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + '@vitest/coverage-v8@3.2.6': + resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==} peerDependencies: - '@vitest/browser': 3.2.4 - vitest: '>=3.2.6 <4' + '@vitest/browser': 3.2.6 + vitest: 3.2.6 peerDependenciesMeta: '@vitest/browser': optional: true @@ -4453,57 +4217,6 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -4543,19 +4256,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv-keywords@5.1.0: - resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} - peerDependencies: - ajv: ^8.8.2 - ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -4578,6 +4278,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -4650,10 +4353,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - aws-ssl-profiles@1.1.2: - resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} - engines: {node: '>= 6.0.0'} - axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} @@ -4675,6 +4374,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -4690,15 +4392,11 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.8.12: - resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} - hasBin: true - big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -4718,13 +4416,19 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} - engines: {node: '>=18'} + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} bowser@2.12.1: resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -4732,13 +4436,8 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} - browserslist@4.26.3: - resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -4789,8 +4488,8 @@ packages: caniuse-lite@1.0.30001748: resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4822,10 +4521,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -4901,6 +4596,9 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -4919,10 +4617,6 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.1.0: - resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} - engines: {node: '>=18'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -5307,7 +5001,7 @@ packages: gel: '>=2' knex: '*' kysely: '*' - mysql2: '>=2' + mysql2: 3.22.0 pg: '>=8' postgres: '>=3' prisma: '*' @@ -5397,11 +5091,8 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.230: - resolution: {integrity: sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ==} - - electron-to-chromium@1.5.352: - resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + electron-to-chromium@1.5.419: + resolution: {integrity: sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==} embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} @@ -5448,10 +5139,6 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.24.5: - resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} - engines: {node: '>=10.13.0'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -5471,9 +5158,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -5520,10 +5204,6 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -5558,10 +5238,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -5593,10 +5269,6 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -5658,14 +5330,14 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fast-uri@4.1.3: - resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - fast-xml-parser@5.8.0: - resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} hasBin: true fastdom@1.0.12: @@ -5773,9 +5445,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-monkey@1.1.0: - resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -5812,9 +5481,6 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - generate-function@2.3.1: - resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -5859,12 +5525,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regex.js@1.2.0: - resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -5972,10 +5632,6 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - heap-js@2.7.1: - resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} - engines: {node: '>=10.0.0'} - helmet@8.1.0: resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} engines: {node: '>=18.0.0'} @@ -6026,10 +5682,6 @@ packages: engines: {node: '>=18'} hasBin: true - hyperdyperid@1.2.0: - resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} - engines: {node: '>=10.18'} - i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} @@ -6041,12 +5693,12 @@ packages: typescript: optional: true - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} idb@7.1.1: @@ -6200,9 +5852,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-property@1.0.2: - resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -6235,6 +5884,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -6288,10 +5940,6 @@ packages: engines: {node: '>=10'} hasBin: true - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -6645,10 +6293,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru.min@1.1.4: - resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} - engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@0.453.0: resolution: {integrity: sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==} peerDependencies: @@ -6743,19 +6387,9 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - media-typer@1.1.1: - resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} - engines: {node: '>= 0.8'} - - memfs@4.68.1: - resolution: {integrity: sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==} - merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - mermaid@11.17.2: resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} @@ -6854,18 +6488,10 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -6897,49 +6523,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minimizer-webpack-plugin@5.6.1: - resolution: {integrity: sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@minify-html/node': '*' - '@swc/core': '*' - '@swc/css': '*' - '@swc/html': '*' - clean-css: '*' - cssnano: '*' - csso: '*' - esbuild: '*' - html-minifier-terser: '*' - lightningcss: '*' - postcss: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@minify-html/node': - optional: true - '@swc/core': - optional: true - '@swc/css': - optional: true - '@swc/html': - optional: true - clean-css: - optional: true - cssnano: - optional: true - csso: - optional: true - esbuild: - optional: true - html-minifier-terser: - optional: true - lightningcss: - optional: true - postcss: - optional: true - uglify-js: - optional: true - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -6987,19 +6570,9 @@ packages: murmurhash-js@1.0.0: resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} - mysql2@3.19.0: - resolution: {integrity: sha512-760Ay9HViAUT7V8QkYxrIx/LHJPGWtE+D/31VuiDm1eu2IFaUIqMcK7+hVHnmgnC7JnnwreFKsZoa8K6BnGl8Q==} - engines: {node: '>= 8.0'} - peerDependencies: - '@types/node': '>= 8' - - named-placeholders@1.1.6: - resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} - engines: {node: '>=8.0.0'} - - nanoid@6.0.1: - resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} - engines: {node: ^22 || ^24 || >=26} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} hasBin: true natural-compare@1.4.0: @@ -7013,9 +6586,6 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -7043,14 +6613,12 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} - node-releases@2.0.23: - resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} - - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} - nodemailer@9.0.6: - resolution: {integrity: sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==} + nodemailer@9.1.0: + resolution: {integrity: sha512-xj1Ri5Sau3qpPffHJwi2bY0oWVVWYq62Ph17l+2v1xXpxjqTls3YPc3L8dub9mcJpxj+1ucNnuYVqLlgh4pmDA==} engines: {node: '>=6.0.0'} normalize-path@3.0.0: @@ -7143,8 +6711,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} path-is-absolute@1.0.1: @@ -7264,9 +6832,9 @@ packages: png-js@1.0.0: resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} - pnpm@11.24.0: - resolution: {integrity: sha512-vSfjRel23LC+C3oSKCF7BJqBfiGx81XJDb59xGZxiVqLwebQbCRVRQXqk+oLRfSJon7Bv7yN5qlln8oPFvoAAA==} - engines: {node: '>=22.13'} + pnpm@10.34.5: + resolution: {integrity: sha512-pO4F8vc2WCVb1qiYWcBlpFwopX2u+uLIk6Fo7itzFow3uR6D5X6mdlStA/AwMXRkMOi84442LgQmBfuKvIAZLg==} + engines: {node: '>=18.12'} hasBin: true points-on-curve@0.2.0: @@ -7396,9 +6964,9 @@ packages: rate-limiter-flexible@11.0.1: resolution: {integrity: sha512-hvyCUefjRund2N6hro2H8Dql7OvB6+B3Qv2FLWWbdsdyQScXf4+N0tM0Bryz11awTtNxx6C1QbHYb1rCiAx7pA==} - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} react-day-picker@9.11.1: resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} @@ -7666,9 +7234,6 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -7701,10 +7266,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} - engines: {node: '>= 10.13.0'} - secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -7813,12 +7374,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-loader@5.0.0: - resolution: {integrity: sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.72.1 - source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -7826,10 +7381,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -7846,10 +7397,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sql-escaper@1.3.3: - resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} - engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -7951,8 +7498,8 @@ packages: '@types/node': optional: true - strnum@2.3.0: - resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} style-to-js@1.1.18: resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} @@ -7979,10 +7526,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -7990,12 +7533,6 @@ packages: svix@1.84.1: resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} - swc-loader@0.2.7: - resolution: {integrity: sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==} - peerDependencies: - '@swc/core': ^1.2.147 - webpack: '>=2' - tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -8011,10 +7548,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -8040,12 +7573,6 @@ packages: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} - thingies@2.6.1: - resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} - engines: {node: '>=10.18'} - peerDependencies: - tslib: ^2 - thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} @@ -8102,12 +7629,6 @@ packages: traverse@0.3.9: resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} - tree-dump@1.1.0: - resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -8152,10 +7673,6 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -8222,9 +7739,6 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unionfs@4.6.0: - resolution: {integrity: sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==} - unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -8268,17 +7782,11 @@ packages: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: 4.28.7 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -8321,6 +7829,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true @@ -8439,10 +7951,6 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} - watchpack@2.5.2: - resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} - engines: {node: '>=10.13.0'} - web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -8458,22 +7966,8 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - webpack-sources@3.5.1: - resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} - engines: {node: '>=10.13.0'} - - webpack@5.109.2: - resolution: {integrity: sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} @@ -8586,6 +8080,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -9169,13 +8667,13 @@ snapshots: '@aws-sdk/xml-builder@3.901.0': dependencies: '@smithy/types': 4.6.0 - fast-xml-parser: 5.8.0 + fast-xml-parser: 5.11.1 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.17': dependencies: '@smithy/types': 4.14.0 - fast-xml-parser: 5.8.0 + fast-xml-parser: 5.11.1 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} @@ -9224,7 +8722,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -9344,10 +8842,6 @@ snapshots: dependencies: '@babel/types': 7.28.4 - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 @@ -10254,134 +9748,6 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/fs-core@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-node-builtins': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - thingies: 2.6.1(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-fsa@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-core': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - thingies: 2.6.1(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node-builtins@4.68.1(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/fs-node-to-fsa@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-fsa': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node-utils@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-node-builtins': 4.68.1(tslib@2.8.1) - glob-to-regex.js: 1.2.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-core': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.68.1(tslib@2.8.1) - glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.1(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-print@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-snapshot@4.68.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) - '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - hyperdyperid: 1.2.0 - thingies: 2.6.1(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) - hyperdyperid: 1.2.0 - thingies: 2.6.1(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) - tslib: 2.8.1 - '@mapbox/jsonlint-lines-primitives@2.0.2': {} '@mapbox/point-geometry@1.1.0': {} @@ -10434,7 +9800,7 @@ snapshots: '@noble/hashes@1.8.0': {} - '@nodable/entities@2.1.0': {} + '@nodable/entities@3.0.0': {} '@opentelemetry/api-logs@0.214.0': dependencies: @@ -10446,10 +9812,10 @@ snapshots: '@opentelemetry/api@1.9.1': {} - '@opentelemetry/auto-instrumentations-node@0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))': + '@opentelemetry/auto-instrumentations-node@0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-amqplib': 0.64.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-aws-lambda': 0.69.0(@opentelemetry/api@1.9.1) @@ -10516,6 +9882,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.217.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.4 @@ -11041,10 +10412,10 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@opentelemetry/propagator-jaeger@2.10.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/propagator-jaeger@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/redis-common@0.38.3': {} @@ -11136,7 +10507,7 @@ snapshots: '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/propagator-b3': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/propagator-jaeger': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) @@ -12550,71 +11921,10 @@ snapshots: magic-string: 0.25.9 string.prototype.matchall: 4.0.12 - '@swc/core-darwin-arm64@1.15.47': - optional: true - - '@swc/core-darwin-x64@1.15.47': - optional: true - - '@swc/core-linux-arm-gnueabihf@1.15.47': - optional: true - - '@swc/core-linux-arm64-gnu@1.15.47': - optional: true - - '@swc/core-linux-arm64-musl@1.15.47': - optional: true - - '@swc/core-linux-ppc64-gnu@1.15.47': - optional: true - - '@swc/core-linux-s390x-gnu@1.15.47': - optional: true - - '@swc/core-linux-x64-gnu@1.15.47': - optional: true - - '@swc/core-linux-x64-musl@1.15.47': - optional: true - - '@swc/core-win32-arm64-msvc@1.15.47': - optional: true - - '@swc/core-win32-ia32-msvc@1.15.47': - optional: true - - '@swc/core-win32-x64-msvc@1.15.47': - optional: true - - '@swc/core@1.15.47(@swc/helpers@0.5.19)': - dependencies: - '@swc/counter': 0.1.3 - '@swc/types': 0.1.28 - optionalDependencies: - '@swc/core-darwin-arm64': 1.15.47 - '@swc/core-darwin-x64': 1.15.47 - '@swc/core-linux-arm-gnueabihf': 1.15.47 - '@swc/core-linux-arm64-gnu': 1.15.47 - '@swc/core-linux-arm64-musl': 1.15.47 - '@swc/core-linux-ppc64-gnu': 1.15.47 - '@swc/core-linux-s390x-gnu': 1.15.47 - '@swc/core-linux-x64-gnu': 1.15.47 - '@swc/core-linux-x64-musl': 1.15.47 - '@swc/core-win32-arm64-msvc': 1.15.47 - '@swc/core-win32-ia32-msvc': 1.15.47 - '@swc/core-win32-x64-msvc': 1.15.47 - '@swc/helpers': 0.5.19 - - '@swc/counter@0.1.3': {} - '@swc/helpers@0.5.19': dependencies: tslib: 2.8.1 - '@swc/types@0.1.28': - dependencies: - '@swc/counter': 0.1.3 - '@tailwindcss/node@4.1.14': dependencies: '@jridgewell/remapping': 2.3.5 @@ -12698,11 +12008,6 @@ snapshots: '@tanstack/query-core': 5.90.2 react: 19.2.1 - '@temporalio/activity@1.22.0': - dependencies: - '@temporalio/client': 1.22.0 - '@temporalio/common': 1.22.0 - '@temporalio/client@1.15.0': dependencies: '@grpc/grpc-js': 1.14.4 @@ -12710,17 +12015,7 @@ snapshots: '@temporalio/proto': 1.15.0 abort-controller: 3.0.0 long: 5.3.2 - uuid: 14.0.0 - - '@temporalio/client@1.22.0': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@temporalio/common': 1.22.0 - '@temporalio/proto': 1.22.0 - abort-controller: 3.0.0 - long: 5.3.2 - nexus-rpc: 0.0.2 - uuid: 14.0.0 + uuid: 11.1.1 '@temporalio/common@1.15.0': dependencies: @@ -12738,19 +12033,6 @@ snapshots: nexus-rpc: 0.0.2 proto3-json-serializer: 2.0.2 - '@temporalio/core-bridge@1.22.0': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@temporalio/common': 1.22.0 - - '@temporalio/nexus@1.22.0': - dependencies: - '@temporalio/client': 1.22.0 - '@temporalio/common': 1.22.0 - '@temporalio/proto': 1.22.0 - long: 5.3.2 - nexus-rpc: 0.0.2 - '@temporalio/proto@1.15.0': dependencies: long: 5.3.2 @@ -12761,43 +12043,6 @@ snapshots: long: 5.3.2 protobufjs: 8.8.0 - '@temporalio/worker@1.22.0(@swc/helpers@0.5.19)(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@swc/core': 1.15.47(@swc/helpers@0.5.19) - '@temporalio/activity': 1.22.0 - '@temporalio/client': 1.22.0 - '@temporalio/common': 1.22.0 - '@temporalio/core-bridge': 1.22.0 - '@temporalio/nexus': 1.22.0 - '@temporalio/proto': 1.22.0 - '@temporalio/workflow': 1.22.0 - heap-js: 2.7.1 - memfs: 4.68.1 - nexus-rpc: 0.0.2 - protobufjs: 8.8.0 - rxjs: 7.8.2 - source-map: 0.7.6 - source-map-loader: 5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)) - supports-color: 8.1.1 - swc-loader: 0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.19))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)) - unionfs: 4.6.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26) - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/css' - - '@swc/helpers' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - uglify-js - - webpack-cli - '@temporalio/workflow@1.22.0': dependencies: '@temporalio/common': 1.22.0 @@ -13009,8 +12254,6 @@ snapshots: '@types/estree@0.0.39': {} - '@types/estree@1.0.8': {} - '@types/estree@1.0.9': {} '@types/express-serve-static-core@4.19.6': @@ -13278,7 +12521,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.33.0)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.33.0)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -13288,7 +12531,7 @@ snapshots: istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.19 + magic-string: 0.30.21 magicast: 0.3.5 std-env: 3.9.0 test-exclude: 7.0.2 @@ -13343,86 +12586,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@webassemblyjs/ast@1.14.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - - '@webassemblyjs/helper-api-error@1.13.2': {} - - '@webassemblyjs/helper-buffer@1.14.1': {} - - '@webassemblyjs/helper-numbers@1.13.2': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - - '@webassemblyjs/helper-wasm-section@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 - - '@webassemblyjs/ieee754@1.13.2': - dependencies: - '@xtuc/ieee754': 1.2.0 - - '@webassemblyjs/leb128@1.13.2': - dependencies: - '@xtuc/long': 4.2.2 - - '@webassemblyjs/utf8@1.13.2': {} - - '@webassemblyjs/wasm-edit@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 - - '@webassemblyjs/wasm-gen@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wasm-opt@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - - '@webassemblyjs/wasm-parser@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wast-printer@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 - - '@xtuc/ieee754@1.2.0': {} - - '@xtuc/long@4.2.2': {} - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -13454,15 +12617,6 @@ snapshots: agent-base@7.1.4: {} - ajv-formats@2.1.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - - ajv-keywords@5.1.0(ajv@8.20.0): - dependencies: - ajv: 8.20.0 - fast-deep-equal: 3.1.3 - ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -13473,7 +12627,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 4.1.3 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -13487,6 +12641,8 @@ snapshots: ansi-styles@6.2.3: {} + anynum@1.0.1: {} + append-field@1.0.0: {} archiver-utils@2.1.0: @@ -13575,7 +12731,7 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.26): dependencies: - browserslist: 4.26.3 + browserslist: 4.28.7 caniuse-lite: 1.0.30001748 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -13587,9 +12743,6 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - aws-ssl-profiles@1.1.2: - optional: true - axios@1.20.0: dependencies: follow-redirects: 1.16.0 @@ -13626,6 +12779,8 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} base64-js@0.0.8: {} @@ -13634,9 +12789,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.27: {} - - baseline-browser-mapping@2.8.12: {} + baseline-browser-mapping@2.11.20: {} big-integer@1.6.52: {} @@ -13657,22 +12810,34 @@ snapshots: bn.js@4.12.3: {} - body-parser@2.3.0: + body-parser@1.20.6: dependencies: bytes: 3.1.2 - content-type: 2.1.0 - debug: 4.4.3 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.4.24 on-finished: 2.4.1 qs: 6.15.3 - raw-body: 3.0.2 - type-is: 2.1.0 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 transitivePeerDependencies: - supports-color bowser@2.12.1: {} + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -13681,21 +12846,13 @@ snapshots: dependencies: base64-js: 1.5.1 - browserslist@4.26.3: - dependencies: - baseline-browser-mapping: 2.8.12 - caniuse-lite: 1.0.30001748 - electron-to-chromium: 1.5.230 - node-releases: 2.0.23 - update-browserslist-db: 1.1.3(browserslist@4.26.3) - - browserslist@4.28.2: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.352 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.419 + node-releases: 2.0.54 + update-browserslist-db: 1.2.3(browserslist@4.28.7) buffer-crc32@0.2.13: {} @@ -13739,7 +12896,7 @@ snapshots: caniuse-lite@1.0.30001748: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001810: {} ccount@2.0.1: {} @@ -13767,8 +12924,6 @@ snapshots: chownr@3.0.0: {} - chrome-trace-event@1.0.4: {} - cjs-module-lexer@2.2.0: {} class-variance-authority@0.7.1: @@ -13846,6 +13001,8 @@ snapshots: transitivePeerDependencies: - supports-color + concat-map@0.0.1: {} + concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -13863,8 +13020,6 @@ snapshots: content-type@1.0.5: {} - content-type@2.1.0: {} - convert-source-map@2.0.0: {} cookie-signature@1.0.6: {} @@ -13885,7 +13040,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 core-util-is@1.0.3: {} @@ -14218,11 +13373,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.18.0)(mysql2@3.19.0(@types/node@24.7.0))(pg@8.20.0): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.18.0)(pg@8.20.0): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.18.0 - mysql2: 3.19.0(@types/node@24.7.0) pg: 8.20.0 dunder-proto@1.0.1: @@ -14249,9 +13403,7 @@ snapshots: dependencies: jake: 10.9.4 - electron-to-chromium@1.5.230: {} - - electron-to-chromium@1.5.352: {} + electron-to-chromium@1.5.419: {} embla-carousel-react@8.6.0(react@19.2.1): dependencies: @@ -14313,11 +13465,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - enhanced-resolve@5.24.5: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - entities@6.0.1: {} es-abstract@1.24.2: @@ -14383,8 +13530,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.3.1: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -14481,11 +13626,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -14548,8 +13688,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -14560,7 +13698,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -14570,8 +13708,6 @@ snapshots: eventemitter3@4.0.7: {} - events@3.3.0: {} - exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -14604,7 +13740,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 2.3.0 + body-parser: 1.20.6 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.1 @@ -14659,20 +13795,21 @@ snapshots: fast-sha256@1.3.0: {} - fast-uri@4.1.3: {} + fast-uri@3.1.6: {} fast-xml-builder@1.2.0: dependencies: - path-expression-matcher: 1.5.0 + path-expression-matcher: 1.6.2 xml-naming: 0.1.0 - fast-xml-parser@5.8.0: + fast-xml-parser@5.11.1: dependencies: - '@nodable/entities': 2.1.0 + '@nodable/entities': 3.0.0 fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.3.0 - xml-naming: 0.1.0 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 fastdom@1.0.12: dependencies: @@ -14786,8 +13923,6 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-monkey@1.1.0: {} - fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -14832,11 +13967,6 @@ snapshots: transitivePeerDependencies: - supports-color - generate-function@2.3.1: - dependencies: - is-property: 1.0.2 - optional: true - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -14885,10 +14015,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regex.js@1.2.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -15075,8 +14201,6 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - heap-js@2.7.1: {} - helmet@8.1.0: {} help-me@5.0.0: {} @@ -15131,8 +14255,6 @@ snapshots: husky@9.1.7: {} - hyperdyperid@1.2.0: {} - i18next-browser-languagedetector@8.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -15143,11 +14265,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 - iconv-lite@0.6.3: + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.2: + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -15298,9 +14420,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-property@1.0.2: - optional: true - is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -15333,6 +14452,8 @@ snapshots: dependencies: which-typed-array: 1.1.20 + is-unsafe@2.0.2: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -15389,12 +14510,6 @@ snapshots: filelist: 1.0.6 picocolors: 1.1.1 - jest-worker@27.5.1: - dependencies: - '@types/node': 24.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - jiti@2.6.1: {} jose@6.1.0: {} @@ -15662,9 +14777,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru.min@1.1.4: - optional: true - lucide-react@0.453.0(react@19.2.1): dependencies: react: 19.2.1 @@ -15687,8 +14799,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@4.0.0: @@ -15890,29 +15002,8 @@ snapshots: media-typer@0.3.0: {} - media-typer@1.1.1: {} - - memfs@4.68.1: - dependencies: - '@jsonjoy.com/fs-core': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-fsa': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-to-fsa': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.68.1(tslib@2.8.1) - '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.1(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - merge-descriptors@1.0.3: {} - merge-stream@2.0.0: {} - mermaid@11.17.2: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -16145,16 +15236,10 @@ snapshots: mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - mime@1.6.0: {} mime@2.6.0: {} @@ -16167,31 +15252,18 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 1.1.18 minimatch@5.1.9: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 2.1.4 minimatch@9.0.9: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 2.1.4 minimist@1.2.8: {} - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - terser: 5.47.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26) - optionalDependencies: - '@swc/core': 1.15.47(@swc/helpers@0.5.19) - esbuild: 0.25.12 - lightningcss: 1.33.0 - postcss: 8.5.26 - minipass@7.1.3: {} minizlib@3.1.0: @@ -16236,25 +15308,7 @@ snapshots: murmurhash-js@1.0.0: {} - mysql2@3.19.0(@types/node@24.7.0): - dependencies: - '@types/node': 24.7.0 - aws-ssl-profiles: 1.1.2 - denque: 2.1.0 - generate-function: 2.3.1 - iconv-lite: 0.7.2 - long: 5.3.2 - lru.min: 1.1.4 - named-placeholders: 1.1.6 - sql-escaper: 1.3.3 - optional: true - - named-placeholders@1.1.6: - dependencies: - lru.min: 1.1.4 - optional: true - - nanoid@6.0.1: {} + nanoid@5.1.16: {} natural-compare@1.4.0: {} @@ -16262,8 +15316,6 @@ snapshots: negotiator@0.6.4: {} - neo-async@2.6.2: {} - next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: react: 19.2.1 @@ -16283,11 +15335,9 @@ snapshots: node-forge@1.4.0: {} - node-releases@2.0.23: {} - - node-releases@2.0.38: {} + node-releases@2.0.54: {} - nodemailer@9.0.6: {} + nodemailer@9.1.0: {} normalize-path@3.0.0: {} @@ -16379,7 +15429,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} + path-expression-matcher@1.6.2: {} path-is-absolute@1.0.1: {} @@ -16523,7 +15573,7 @@ snapshots: png-js@1.0.0: {} - pnpm@11.24.0: {} + pnpm@10.34.5: {} points-on-curve@0.2.0: {} @@ -16545,7 +15595,7 @@ snapshots: postcss@8.5.26: dependencies: - nanoid: 6.0.1 + nanoid: 5.1.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -16626,11 +15676,11 @@ snapshots: rate-limiter-flexible@11.0.1: {} - raw-body@3.0.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.4.24 unpipe: 1.0.0 react-day-picker@9.11.1(react@19.2.1): @@ -16994,10 +16044,6 @@ snapshots: rw@1.3.3: {} - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -17031,13 +16077,6 @@ snapshots: scheduler@0.27.0: {} - schema-utils@4.3.3: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) - ajv-keywords: 5.1.0(ajv@8.20.0) - secure-json-parse@4.1.0: {} semver@6.3.1: {} @@ -17202,12 +16241,6 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)): - dependencies: - iconv-lite: 0.6.3 - source-map-js: 1.2.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26) - source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -17215,8 +16248,6 @@ snapshots: source-map@0.6.1: {} - source-map@0.7.6: {} - source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 @@ -17227,9 +16258,6 @@ snapshots: split2@4.2.0: {} - sql-escaper@1.3.3: - optional: true - stackback@0.0.2: {} standard-as-callback@2.1.0: {} @@ -17364,7 +16392,9 @@ snapshots: optionalDependencies: '@types/node': 24.7.0 - strnum@2.3.0: {} + strnum@2.4.2: + dependencies: + anynum: 1.0.1 style-to-js@1.1.18: dependencies: @@ -17406,10 +16436,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} svix@1.84.1: @@ -17417,12 +16443,6 @@ snapshots: standardwebhooks: 1.0.0 uuid: 14.0.0 - swc-loader@0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.19))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)): - dependencies: - '@swc/core': 1.15.47(@swc/helpers@0.5.19) - '@swc/counter': 0.1.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26) - tailwind-merge@3.3.1: {} tailwindcss-animate@1.0.7(tailwindcss@4.1.14): @@ -17433,8 +16453,6 @@ snapshots: tapable@2.3.0: {} - tapable@2.3.3: {} - tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -17473,10 +16491,6 @@ snapshots: glob: 10.5.0 minimatch: 10.2.5 - thingies@2.6.1(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - thread-stream@4.0.0: dependencies: real-require: 0.2.0 @@ -17519,10 +16533,6 @@ snapshots: traverse@0.3.9: {} - tree-dump@1.1.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -17562,12 +16572,6 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.1.0: - dependencies: - content-type: 2.1.0 - media-typer: 1.1.1 - mime-types: 3.0.2 - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -17658,10 +16662,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unionfs@4.6.0: - dependencies: - fs-monkey: 1.1.0 - unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -17724,15 +16724,9 @@ snapshots: upath@1.2.0: {} - update-browserslist-db@1.1.3(browserslist@4.26.3): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.26.3 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 @@ -17767,6 +16761,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@11.1.1: {} + uuid@14.0.0: {} vary@1.1.2: {} @@ -17838,7 +16834,7 @@ snapshots: '@medv/finder': 4.0.2 clsx: 2.1.1 modern-screenshot: 4.6.6 - nanoid: 6.0.1 + nanoid: 5.1.16 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) tailwind-merge: 3.3.1 @@ -17915,10 +16911,6 @@ snapshots: void-elements@3.1.0: {} - watchpack@2.5.2: - dependencies: - graceful-fs: 4.2.11 - web-namespaces@2.0.1: {} web-push@3.6.7: @@ -17935,44 +16927,6 @@ snapshots: webidl-conversions@4.0.2: {} - webpack-sources@3.5.1: {} - - webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26): - dependencies: - '@types/estree': 1.0.9 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.16.0 - browserslist: 4.28.2 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.5 - es-module-lexer: 2.3.1 - eslint-scope: 5.1.1 - events: 3.3.0 - graceful-fs: 4.2.11 - mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.19))(esbuild@0.25.12)(lightningcss@1.33.0)(postcss@8.5.26)) - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.0 - watchpack: 2.5.2 - webpack-sources: 3.5.1 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - uglify-js - whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -18169,6 +17123,8 @@ snapshots: xml-naming@0.1.0: {} + xml-naming@0.3.0: {} + xmlchars@2.2.0: {} xmlhttprequest-ssl@2.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..1d827cab --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,137 @@ +# pnpm settings must be workspace-scoped for pnpm 10.34+. +# Values migrated from package.json without changing their constraints. +packages: + - "." +overrides: + "browserslist": "4.28.7" + "mysql2": "3.22.0" + "minimatch@9>brace-expansion": "2.1.4" + "tailwindcss>nanoid": "3.3.7" + "fast-xml-parser": ">=5.7.0" + "esbuild@<=0.24.2": ">=0.25.0" + "tar@=7.5.1": ">=7.5.2" + "pnpm@>=10.0.0 <10.26.0": ">=10.26.0" + "pnpm@<10.26.0": ">=10.26.0" + "pnpm@>=6.25.0 <10.27.0": ">=10.27.0" + "lodash-es@>=4.0.0 <=4.17.22": ">=4.17.23" + "lodash@>=4.0.0 <=4.17.22": ">=4.17.23" + "pnpm@<10.28.2": ">=10.28.2" + "pnpm@<10.28.1": ">=10.28.1" + "tar@<7.5.7": ">=7.5.7" + "mdast-util-to-hast@>=13.0.0 <13.2.1": ">=13.2.1" + "qs@>=6.7.0 <=6.14.1": ">=6.14.2" + "tar@<=7.5.2": ">=7.5.3" + "tar@<7.5.8": ">=7.5.8" + "rollup@>=4.0.0 <4.59.0": ">=4.59.0" + "serialize-javascript@<=7.0.2": ">=7.0.3" + "qs@<6.14.1": ">=6.14.1" + "tar@<=7.5.9": ">=7.5.10" + "tar@<=7.5.10": ">=7.5.11" + "tar@<=7.5.3": ">=7.5.4" + "socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6" + "picomatch@>=4.0.0 <4.0.4": ">=4.0.4" + "yaml@>=2.0.0 <2.8.3": ">=2.8.3" + "serialize-javascript@<7.0.5": ">=7.0.5" + "lodash-es@>=4.0.0 <=4.17.23": ">=4.18.0" + "lodash@>=4.0.0 <=4.17.23": ">=4.18.0" + "lodash-es@<=4.17.23": ">=4.18.0" + "lodash@<=4.17.23": ">=4.18.0" + "vite@<=6.4.1": ">=6.4.2" + "drizzle-orm@<0.45.2": ">=0.45.2" + "nodemailer@<=8.0.4": ">=8.0.5" + "follow-redirects@<=1.15.11": ">=1.16.0" + "dompurify@<=3.3.3": ">=3.4.0" + "qs": ">=6.14.2" + "lodash": ">=4.17.21" + "lodash-es": ">=4.17.21" + "serialize-javascript": "7.0.5" + "yaml": ">=2.7.1" + "picomatch": ">=4.0.2" + "follow-redirects": ">=1.15.6" + "dompurify": ">=3.2.4" + "protobufjs@<=7.5.5": ">=7.5.6" + "protobufjs@>=8.0.0 <=8.0.1": ">=8.0.2" + "fast-xml-builder@<=1.1.6": ">=1.1.7" + "fast-xml-parser@<=5.7.1": ">=5.7.2" + "prom-client@<=15.1.3": ">=15.1.4" + "postcss@<8.5.10": ">=8.5.10" + "uuid@>=11.0.0 <11.1.1": ">=11.1.1" + "ip-address@<10.1.1": ">=10.1.1" + "mermaid@<11.15.0": ">=11.15.0" + "ws@>=8.0.0 <8.20.1": ">=8.20.1" + "uuid@<11.1.1": ">=11.1.1" + "qs@>=6.11.1 <=6.15.1": ">=6.15.2" + "brace-expansion@>=5.0.0 <5.0.6": ">=5.0.6" + "@grpc/grpc-js@>=1.14.0 <1.14.4": ">=1.14.4" + "tmp@<0.2.6": ">=0.2.6" + "esbuild@>=0.27.3 <0.28.1": ">=0.28.1" + "form-data@>=4.0.0 <4.0.6": ">=4.0.6" + "tar@<=7.5.15": ">=7.5.16" + "vite@>=7.0.0 <=7.3.4": ">=7.3.5 <8" + "nodemailer@<=8.0.8": ">=8.0.9" + "nodemailer@<=8.0.7": ">=8.0.8" + "dompurify@<=3.4.5": ">=3.4.6" + "dompurify@<=3.4.6": ">=3.4.7" + "multer@>=1.0.0 <2.2.0": ">=2.2.0" + "multer@>=2.0.0-alpha.1 <2.2.0": ">=2.2.0" + "nodemailer@<=9.0.0": ">=9.0.1" + "pnpm@<10.33.4": ">=10.33.4" + "pnpm@<10.34.0": ">=10.34.0" + "pnpm@<10.34.1": ">=10.34.1" + "pnpm@<10.34.2": ">=10.34.2" + "pnpm@<10.34.4": ">=10.34.4" + "ws@>=8.0.0 <8.21.0": ">=8.21.0" + "protobufjs@>=8.2.0 <=8.4.2": ">=8.5.0" + "protobufjs@>=8.0.0 <=8.4.0": ">=8.4.1" + "protobufjs@>=8.0.0 <=8.5.0": ">=8.6.0" + "@babel/core@<=7.29.0": ">=7.29.6 <8" + "axios@>=1.0.0 <1.18.0": ">=1.18.0" + "brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2" + "brace-expansion@<1.1.16": ">=1.1.16" + "brace-expansion@>=3.0.0 <5.0.7": ">=5.0.7" + "tar@<=7.5.17": ">=7.5.18" + "tar@<=7.5.18": ">=7.5.19" + "tar@<=7.5.16": ">=7.5.17" + "engine.io@>=4.1.0 <6.6.7": ">=6.6.7" + "axios@>=1.7.0 <1.18.0": ">=1.18.0" + "axios@>=1.15.0 <1.18.0": ">=1.18.0" + "protobufjs@>=8.2.0 <=8.6.4": ">=8.6.5" + "protobufjs@>=8.0.0 <=8.6.5": ">=8.6.6" + "axios@>=1.15.2 <1.18.0": ">=1.18.0" + "axios@>=1.15.1 <1.18.0": ">=1.18.0" + "axios@>=1.13.0 <1.18.0": ">=1.18.0" + "body-parser@<1.20.6": ">=1.20.6" + "@opentelemetry/propagator-jaeger@<2.9.0": ">=2.9.0" + "dompurify@<=3.4.11": ">=3.4.12" + "fast-uri@>=3.0.0 <=3.1.3": ">=3.1.4" + "dompurify@<=3.4.10": ">=3.4.11" + "dompurify@<3.4.9": ">=3.4.9" + "dompurify@>=3.0.0 <=3.4.7": ">=3.4.8" + "dompurify@<3.4.7": ">=3.4.7" + "brace-expansion@<1.1.17": ">=1.1.17" + "brace-expansion@>=2.0.0 <2.1.3": ">=2.1.3" + "brace-expansion@>=4.0.0 <5.0.8": ">=5.0.8" + "postcss@<=8.5.22": ">=8.5.23" + "socket.io-parser@>=4.0.0 <4.2.7": ">=4.2.7" + "fast-uri@>=3.0.0 <3.1.5": ">=3.1.5" + "ip-address@<=10.3.0": ">=10.3.1" + "ip-address@>=10.1.1 <=10.2.1": ">=10.2.2" + "ip-address@>=10.1.1 <=10.2.0": ">=10.2.1" + "brace-expansion@>=4.0.0 <5.0.9": ">=5.0.9" + "brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4" + "brace-expansion@<1.1.18": ">=1.1.18" + "mermaid@>=11.0.0-alpha.1 <11.16.1": ">=11.16.1" + "mermaid@>=11.5.0 <11.16.1": ">=11.16.1" + "mermaid@>=11.6.0 <11.16.1": ">=11.16.1" + "dompurify@<=3.4.12": ">=3.4.13" + "nanoid@>=4.0.0 <5.1.16": ">=5.1.16" + "nanoid@<3.3.16": ">=3.3.16" + "nanoid@<3.3.18": ">=3.3.18" + "postcss@<=8.5.17": ">=8.5.18" + "vitest@<3.2.6": ">=3.2.6 <4" + "tar@<=7.5.20": ">=7.5.21" + "@opentelemetry/core@<2.8.0": ">=2.8.0" + "fast-uri@>=3.0.0 <3.1.3": ">=3.1.3" + "protobufjs": ">=8.6.6" +patchedDependencies: + "wouter@3.7.1": "patches/wouter@3.7.1.patch" diff --git a/scripts/ci/apply-production-governance.mjs b/scripts/ci/apply-production-governance.mjs new file mode 100644 index 00000000..d5f6f77a --- /dev/null +++ b/scripts/ci/apply-production-governance.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const API_VERSION = "2026-03-10"; +const DEFAULT_REPOSITORY = "munisp/ndsep"; +const DEFAULT_BRANCH = "production"; +const DEFAULT_ENVIRONMENT = "production-release"; +const DEFAULT_PULL_REQUEST = 19; +const APPLY_CONFIRMATION = "APPLY_PRODUCTION_GOVERNANCE"; + +function stripAnsi(value) { + return String(value).replace(/\u001B\[[0-9;]*[A-Za-z]/g, ""); +} + +function parseRepository(value) { + const [owner, repo, ...extra] = String(value).split("/"); + if (!owner || !repo || extra.length > 0) throw new Error(`Invalid repository '${value}'; expected OWNER/REPO`); + return { owner, repo }; +} + +function assertCondition(condition, message) { + if (!condition) throw new Error(message); +} + +function runGh(argumentsList, { input } = {}) { + return execFileSync("gh", argumentsList, { + encoding: "utf8", + input, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function apiJson(endpoint) { + return JSON.parse( + stripAnsi( + runGh(["api", "-H", "Accept: application/vnd.github+json", "-H", `X-GitHub-Api-Version: ${API_VERSION}`, endpoint]) + ) + ); +} + +function putJson(endpoint, payload) { + return JSON.parse( + stripAnsi( + runGh( + [ + "api", + "--method", + "PUT", + "-H", + "Accept: application/vnd.github+json", + "-H", + `X-GitHub-Api-Version: ${API_VERSION}`, + endpoint, + ], + { input: JSON.stringify(payload) } + ) + ) + ); +} + +export function buildReviewProtectionPayload() { + return { + dismiss_stale_reviews: true, + require_code_owner_reviews: true, + required_approving_review_count: 2, + require_last_push_approval: true, + }; +} + +export function buildEnvironmentPayload(reviewerId) { + assertCondition(Number.isInteger(reviewerId) && reviewerId > 0, "Reviewer ID must be a positive integer"); + return { + wait_timer: 0, + reviewers: [{ type: "User", id: reviewerId }], + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false, + }, + prevent_self_review: true, + }; +} + +export function assessRootGaps({ pullRequest, codeowners, codeownersErrors, protection, environment }) { + const gaps = []; + const reviews = protection?.required_pull_request_reviews ?? {}; + const environmentRules = environment?.protection_rules ?? []; + const requiredReviewers = environmentRules.find((rule) => rule?.type === "required_reviewers")?.reviewers ?? []; + const policy = environment?.deployment_branch_policy ?? {}; + + if (!pullRequest?.merged_at) gaps.push("pr-not-merged"); + if (codeowners?.path !== ".github/CODEOWNERS" || !codeowners?.content) gaps.push("codeowners-missing"); + if (!Array.isArray(codeownersErrors?.errors) || codeownersErrors.errors.length > 0) gaps.push("codeowners-invalid"); + if (reviews.required_approving_review_count < 2) gaps.push("two-approvals-disabled"); + if (reviews.require_code_owner_reviews !== true) gaps.push("codeowner-review-disabled"); + if (reviews.dismiss_stale_reviews !== true) gaps.push("stale-review-dismissal-disabled"); + if (reviews.require_last_push_approval !== true) gaps.push("last-push-approval-disabled"); + if (protection?.required_status_checks?.strict !== true) gaps.push("strict-status-checks-disabled"); + if (protection?.enforce_admins?.enabled !== true) gaps.push("admin-enforcement-disabled"); + if (protection?.allow_force_pushes?.enabled !== false) gaps.push("force-push-denial-disabled"); + if (protection?.allow_deletions?.enabled !== false) gaps.push("deletion-denial-disabled"); + if (environment?.name !== DEFAULT_ENVIRONMENT) gaps.push("production-release-missing"); + if (requiredReviewers.length === 0) gaps.push("production-release-reviewers-missing"); + if (environment?.prevent_self_review !== true) gaps.push("production-release-self-review-denial-disabled"); + if (environment?.can_admins_bypass !== false) gaps.push("production-release-admin-bypass-enabled"); + if (policy.protected_branches !== true || policy.custom_branch_policies !== false) gaps.push("production-release-branch-policy-disabled"); + + return gaps; +} + +function parseArguments(argumentsList) { + const options = { + repository: DEFAULT_REPOSITORY, + branch: DEFAULT_BRANCH, + environment: DEFAULT_ENVIRONMENT, + pullRequest: DEFAULT_PULL_REQUEST, + reviewer: null, + apply: false, + confirmation: null, + }; + + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + const value = argumentsList[index + 1]; + if (argument === "--repo" && value) { + options.repository = value; + index += 1; + } else if (argument === "--branch" && value) { + options.branch = value; + index += 1; + } else if (argument === "--environment" && value) { + options.environment = value; + index += 1; + } else if (argument === "--pr" && value) { + options.pullRequest = Number(value); + index += 1; + } else if (argument === "--reviewer" && value) { + options.reviewer = value; + index += 1; + } else if (argument === "--apply") { + options.apply = true; + } else if (argument === "--confirm" && value) { + options.confirmation = value; + index += 1; + } else { + throw new Error("Usage: node scripts/ci/apply-production-governance.mjs [--repo OWNER/REPO] [--branch production] [--environment production-release] [--pr 19] [--reviewer GITHUB_LOGIN] [--apply --confirm APPLY_PRODUCTION_GOVERNANCE]"); + } + } + + assertCondition(Number.isInteger(options.pullRequest) && options.pullRequest > 0, "--pr must be a positive integer"); + if (options.apply) { + assertCondition(options.confirmation === APPLY_CONFIRMATION, `--apply requires --confirm ${APPLY_CONFIRMATION}`); + assertCondition(Boolean(options.reviewer), "--apply requires --reviewer GITHUB_LOGIN for the production-release environment"); + } + return options; +} + +function loadLiveState({ owner, repo, branch, environment, pullRequest }) { + const base = `repos/${owner}/${repo}`; + const output = { pullRequest: apiJson(`${base}/pulls/${pullRequest}`) }; + try { + output.codeowners = apiJson(`${base}/contents/.github/CODEOWNERS?ref=${encodeURIComponent(branch)}`); + } catch { + output.codeowners = null; + } + try { + output.codeownersErrors = apiJson(`${base}/codeowners/errors`); + } catch { + output.codeownersErrors = null; + } + output.protection = apiJson(`${base}/branches/${encodeURIComponent(branch)}/protection`); + try { + output.environment = apiJson(`${base}/environments/${encodeURIComponent(environment)}`); + } catch { + output.environment = null; + } + return output; +} + +function resolveReviewer({ owner, reviewer }) { + const reviewerRecord = apiJson(`users/${encodeURIComponent(reviewer)}`); + const actingUser = apiJson("user"); + assertCondition(reviewerRecord?.id, `Unable to resolve GitHub reviewer '${reviewer}'`); + assertCondition(reviewerRecord.login !== actingUser.login, "Refusing to make the current administrator account the sole production-release reviewer"); + assertCondition(reviewerRecord.login !== owner, "Refusing to make the repository owner the sole production-release reviewer"); + return reviewerRecord; +} + +function assertApplyPreconditions({ state, options }) { + assertCondition(state.pullRequest?.merged_at, `PR #${options.pullRequest} is not merged; merge the CODEOWNERS bootstrap through independent review first`); + assertCondition(state.pullRequest?.base?.ref === options.branch, `PR #${options.pullRequest} did not merge into ${options.branch}`); + assertCondition(state.codeowners?.path === ".github/CODEOWNERS" && state.codeowners?.content, `${options.branch} does not contain a usable .github/CODEOWNERS file`); + assertCondition(Array.isArray(state.codeownersErrors?.errors) && state.codeownersErrors.errors.length === 0, `${options.branch} CODEOWNERS validation is unavailable or reports errors`); +} + +export function buildPlan({ repository, branch, environment, pullRequest, state }) { + const { owner, repo } = parseRepository(repository); + const gaps = assessRootGaps(state); + return { + mode: "plan", + repository: `${owner}/${repo}`, + branch, + environment, + pullRequest, + gaps, + actions: [ + "Require PR #19 to be independently approved and normally merged before applying CODEOWNER review enforcement.", + "Validate .github/CODEOWNERS from the production base branch with GitHub's CODEOWNERS errors endpoint.", + "Update only pull-request review protection to require two approvals, CODEOWNER review, stale-review dismissal, and last-push approval.", + "Create or update production-release with required reviewer(s), self-review prevention, and protected-branch-only deployment policy.", + "An environment administrator must separately disable administrator bypass in the GitHub environment protection UI; the documented REST payload does not expose that write field.", + "Run the read-only post-merge governance verifier and retain its live API-backed result.", + ], + }; +} + +export function applyGovernance(options) { + const { owner, repo } = parseRepository(options.repository); + const state = loadLiveState({ owner, repo, branch: options.branch, environment: options.environment, pullRequest: options.pullRequest }); + if (!options.apply) return buildPlan({ ...options, state }); + + assertApplyPreconditions({ state, options }); + const reviewer = resolveReviewer({ owner, reviewer: options.reviewer }); + const base = `repos/${owner}/${repo}`; + const reviewProtection = putJson( + `${base}/branches/${encodeURIComponent(options.branch)}/protection/required_pull_request_reviews`, + buildReviewProtectionPayload() + ); + const environment = putJson( + `${base}/environments/${encodeURIComponent(options.environment)}`, + buildEnvironmentPayload(reviewer.id) + ); + const verifierPath = resolve(dirname(fileURLToPath(import.meta.url)), "verify-post-merge-governance.mjs"); + const verification = execFileSync("node", [verifierPath, "--repo", options.repository, "--branch", options.branch, "--pr", String(options.pullRequest)], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { + mode: "applied", + repository: `${owner}/${repo}`, + branch: options.branch, + environment: options.environment, + reviewProtection, + environmentResult: environment, + verification: JSON.parse(verification), + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = applyGovernance(parseArguments(process.argv.slice(2))); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/ci/collect-pr-readiness-evidence.mjs b/scripts/ci/collect-pr-readiness-evidence.mjs new file mode 100644 index 00000000..7660b16a --- /dev/null +++ b/scripts/ci/collect-pr-readiness-evidence.mjs @@ -0,0 +1,431 @@ +#!/usr/bin/env node +/** + * Read-only PR readiness collector. + * + * This program never creates reviews, approves, merges, dispatches workflows, changes + * GitHub settings, publishes artifacts, or updates visual snapshots. It collects the + * visible PR review/check state and records missing evidence as missing. + * + * Usage: + * node scripts/ci/collect-pr-readiness-evidence.mjs \ + * --repo munisp/ndsep --pr 18 --out-dir /secure/evidence/pr-18 + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const FULL_SHA = /^[a-f0-9]{40}$/i; +const CONFIRMATION = "READ_ONLY_PR_READINESS_COLLECTION"; +const REQUIRED_ACCOUNTABLE_ROLES = [ + "independent engineering reviewer", + "release manager", + "repository administrator", + "environment administrator", + "security owner", + "DBA/platform operations", + "IAM and authorization owners", + "payments and finance control owner", + "SOC/compliance owner", + "SRE/data-protection owner", + "product, QA, and accessibility owners", + "business release owner", +]; + +function usage() { + throw new Error( + "Usage: node scripts/ci/collect-pr-readiness-evidence.mjs --repo owner/repo --pr number --out-dir absolute-path [--confirm READ_ONLY_PR_READINESS_COLLECTION]" + ); +} + +function argument(args, name) { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +function runGh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", GH_FORCE_TTY: "0" }, + }); + } catch (error) { + const detail = + error?.stderr?.toString().trim() || error?.message || String(error); + throw new Error(`GitHub read failed: ${detail}`); + } +} + +function runPlaywrightList() { + try { + const output = execFileSync( + "pnpm", + [ + "exec", + "playwright", + "test", + "e2e/visual-regression.spec.ts", + "--project=chromium", + "--list", + "--reporter=line", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, PLAYWRIGHT_HTML_OPEN: "never" }, + } + ); + return output + .split("\n") + .map(line => line.trim()) + .filter(line => line.includes("snapshot:")); + } catch (error) { + const detail = + error?.stderr?.toString().trim() || error?.message || String(error); + return [`Playwright discovery unavailable: ${detail}`]; + } +} + +function normalizeChecks(items) { + return (items ?? []).map(item => ({ + name: item.name ?? "unnamed-check", + status: item.status ?? "UNKNOWN", + conclusion: item.conclusion ?? "", + detailsUrl: item.detailsUrl ?? null, + })); +} + +function isPassing(check) { + return check.status === "COMPLETED" && check.conclusion === "SUCCESS"; +} + +function markdownEscaped(value) { + return String(value ?? "") + .replaceAll("|", "\\|") + .replaceAll("\n", " "); +} + +function gitHubReviewSummary(pr) { + const head = pr.headRefOid; + const author = pr.author?.login; + const reviews = (pr.reviews ?? []).map(review => ({ + actor: review.author?.login ?? "unknown", + state: review.state, + submittedAt: review.submittedAt ?? null, + commitOid: review.commit?.oid ?? null, + independent: Boolean( + review.author?.login && review.author.login !== author + ), + atCurrentHead: review.commit?.oid === head, + })); + const independentApprovalAtHead = reviews.some( + review => + review.state === "APPROVED" && review.independent && review.atCurrentHead + ); + return { author, reviews, independentApprovalAtHead }; +} + +function buildImmediateBlockers(pr, review, checks) { + const blockers = []; + if (!review.independentApprovalAtHead) + blockers.push({ + id: "GOV-001", + priority: "P0", + owner: "Independent reviewer / release manager", + reason: "No independent approval bound to the current PR head", + }); + for (const check of checks.filter(entry => !isPassing(entry))) { + const lower = check.name.toLowerCase(); + if (lower.includes("visual-regression")) + blockers.push({ + id: "E2E-001", + priority: "P0", + owner: "Product, UX, QA, accessibility and independent code reviewer", + reason: + "Visual regression evidence is not passing; each discovered snapshot requires explicit review before a baseline commit", + }); + else if ( + lower.includes("extended e2e") || + lower.includes("matrix evidence") + ) + blockers.push({ + id: "E2E-002", + priority: "P0", + owner: "QA / release engineering", + reason: + "Mandatory fan-in has no complete all-green, zero-retry evidence set", + }); + else if ( + lower.includes("trivy") || + lower === "security scan" || + lower === "security-gate" + ) + blockers.push({ + id: "SEC-001", + priority: "P0", + owner: "Go service owner / product security", + reason: + "A security gate is failing; do not merge until a fresh scan succeeds or an authorized, time-bounded exception is independently approved", + }); + } + if (pr.mergeStateStatus !== "CLEAN") + blockers.push({ + id: "GOV-002", + priority: "P0", + owner: "Release manager", + reason: `PR merge state is ${pr.mergeStateStatus ?? "UNKNOWN"}`, + }); + return blockers.filter( + (blocker, index, rows) => + rows.findIndex(other => other.id === blocker.id) === index + ); +} + +function complianceBaselineRegister() { + return [ + [ + "GOV-003", + "Protected environments", + "Repository/environment/security administrators", + "Protected staging and production-release environments, reviewer rules, branch restrictions, no-bypass proof, and scoped deployment identities.", + ], + [ + "REL-001", + "Immutable artifact", + "Release engineering", + "Normal merge SHA plus immutable image digest and retained build record.", + ], + [ + "SEC-002", + "Digest-bound assurance", + "Product security / release engineering", + "Direct digest scan, SBOM, provenance, Cosign/Fulcio verification, and policy result bound to one digest.", + ], + [ + "DB-001", + "Database/recovery", + "DBA / platform operations / security", + "Migration, schema drift, TLS/role proof, backup/restore, forward/rollback compatibility, HA/failover, and approved RTO/RPO evidence.", + ], + [ + "IAM-001", + "Identity and authorization", + "IAM / security / application owners", + "Real Keycloak discovery/key rotation and valid/invalid token traces; Permify schema/version and allow/deny traces.", + ], + [ + "MID-001", + "Gateway and middleware", + "Platform and service owners", + "APISIX, Redis, Kafka/Dapr/Fluvio, Temporal, OpenSearch, Lakehouse success and controlled-failure evidence with correlation IDs.", + ], + [ + "FIN-001", + "Financial settlement", + "Payments / finance control / authorized partner operator", + "TigerBeetle and Mojaloop staging/sandbox reconciliation, callback integrity, duplicate/reversal/recovery outcomes.", + ], + [ + "OPS-001", + "Security operations", + "SOC / security / compliance", + "SIEM/Pager delivery, audit-chain/break-glass verification, mTLS/CA rotation drill, and incident response evidence.", + ], + [ + "RES-001", + "Resilience and residency", + "SRE / DBA / data-protection owner", + "Failure injection, recovery/RTO/RPO, residency, retention/deletion, and dependency-outage evidence.", + ], + [ + "UX-001", + "Product and accessibility", + "Product / QA / accessibility / operations representatives", + "Device/browser, critical journeys, offline/sync/replay, accessibility, UX, and UAT acceptance.", + ], + [ + "GOV-004", + "Final release authorization", + "Engineering / security / operations / compliance / business / release owners", + "Digest-bound decision, verified evidence index, named approvals, risk statement, exception register, rollback owner, and communications plan.", + ], + ].map(([id, baseline, owner, evidence]) => ({ + id, + baseline, + owner, + evidence, + status: "not evidenced by this read-only PR collector", + })); +} + +function writeMarkdown(report, file) { + const lines = [ + "# NDSEP Pull-Request Readiness Evidence Report", + "", + `**Generated at:** ${report.generatedAt}`, + `**Repository:** ${report.repository}`, + `**Pull request:** #${report.pullRequest.number} (${report.pullRequest.url})`, + `**Current head:** \`${report.pullRequest.headRefOid}\``, + "", + "## Collection scope", + "", + "This report is generated by a read-only collector. It does not approve, merge, dispatch, deploy, create a baseline, set an environment secret, create an exception, or infer external-service acceptance. A missing item is recorded as missing.", + "", + "## Immediate merge state", + "", + `| Field | Observed value |`, + `|---|---|`, + `| Merge state | ${markdownEscaped(report.pullRequest.mergeStateStatus)} |`, + `| Review decision | ${markdownEscaped(report.pullRequest.reviewDecision)} |`, + `| Independent approval at current head | ${report.review.independentApprovalAtHead ? "yes" : "no"} |`, + `| Automated check counts | ${report.summary.passingChecks} passing / ${report.summary.nonPassingChecks} non-passing or skipped / ${report.summary.totalChecks} total |`, + "", + "## Check rollup", + "", + "| Check | Status | Conclusion | Evidence |", + "|---|---|---|---|", + ...report.checks.map( + check => + `| ${markdownEscaped(check.name)} | ${markdownEscaped(check.status)} | ${markdownEscaped(check.conclusion || "—")} | ${check.detailsUrl ? `[log](${check.detailsUrl})` : "—"} |` + ), + "", + "## Immediate blockers", + "", + "| ID | Priority | Accountable owner | Required closure |", + "|---|---|---|---|", + ...report.immediateBlockers.map( + entry => + `| ${entry.id} | ${entry.priority} | ${entry.owner} | ${entry.reason} |` + ), + "", + "## Visual baseline review set", + "", + "The discovered tests listed below require an explicit reviewed baseline decision. A candidate image is not accepted merely because it exists; the protected matrix must continue to run without `--update-snapshots`, with one worker and zero retries.", + "", + ...report.visualBaselineTests.map(entry => `- ${entry}`), + "", + "## Required compliance approvals and evidence", + "", + "| ID | Baseline | Accountable owner | Required evidence | Status |", + "|---|---|---|---|---|", + ...report.complianceBaselines.map( + entry => + `| ${entry.id} | ${entry.baseline} | ${entry.owner} | ${entry.evidence} | ${entry.status} |` + ), + "", + "## Required accountable roles", + "", + ...report.requiredAccountableRoles.map(role => `- ${role}`), + "", + "## Decision", + "", + `**${report.decision.status}: ${report.decision.reason}**`, + "", + ]; + writeFileSync(file, `${lines.join("\n")}\n`, "utf8"); +} + +function main() { + const args = process.argv.slice(2); + const repo = argument(args, "--repo"); + const prNumber = Number(argument(args, "--pr")); + const outDirRaw = argument(args, "--out-dir"); + const confirm = argument(args, "--confirm") ?? CONFIRMATION; + if ( + !repo || + !/^[\w.-]+\/[\w.-]+$/.test(repo) || + !Number.isInteger(prNumber) || + prNumber < 1 || + !outDirRaw || + !resolve(outDirRaw).startsWith("/") + ) + usage(); + if (confirm !== CONFIRMATION) + throw new Error(`--confirm must equal ${CONFIRMATION}`); + + const outDir = resolve(outDirRaw); + const fields = + "number,url,headRefOid,baseRefName,headRefName,mergeStateStatus,reviewDecision,author,reviews,statusCheckRollup"; + const raw = runGh([ + "pr", + "view", + String(prNumber), + "--repo", + repo, + "--json", + fields, + ]); + const pr = JSON.parse(raw.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); + if (!FULL_SHA.test(pr.headRefOid ?? "")) + throw new Error("GitHub response has no full pull-request head SHA"); + + const review = gitHubReviewSummary(pr); + const checks = normalizeChecks(pr.statusCheckRollup); + const immediateBlockers = buildImmediateBlockers(pr, review, checks); + const visualBaselineTests = runPlaywrightList(); + const complianceBaselines = complianceBaselineRegister(); + const passingChecks = checks.filter(isPassing).length; + const report = { + schemaVersion: 1, + kind: "ndsep-pr-readiness-evidence", + generatedAt: new Date().toISOString(), + collectionMode: "read-only", + repository: repo, + pullRequest: { + number: pr.number, + url: pr.url, + baseRefName: pr.baseRefName, + headRefName: pr.headRefName, + headRefOid: pr.headRefOid, + mergeStateStatus: pr.mergeStateStatus, + reviewDecision: pr.reviewDecision, + }, + review, + checks, + visualBaselineTests, + immediateBlockers, + complianceBaselines, + requiredAccountableRoles: REQUIRED_ACCOUNTABLE_ROLES, + summary: { + totalChecks: checks.length, + passingChecks, + nonPassingChecks: checks.length - passingChecks, + immediateBlockers: immediateBlockers.length, + requiredComplianceBaselines: complianceBaselines.length, + }, + decision: + immediateBlockers.length === 0 + ? { + status: "REVIEW_REQUIRED", + reason: + "Immediate PR checks appear satisfied, but this collector cannot establish protected-environment, artifact, external integration, or accountable release evidence.", + } + : { + status: "BLOCKED", + reason: + "One or more merge or security evidence blockers remain. See immediateBlockers; do not self-approve, force merge, or bypass a protected check.", + }, + }; + + mkdirSync(outDir, { recursive: true, mode: 0o700 }); + const jsonFile = resolve(outDir, "pr-readiness-evidence.json"); + const markdownFile = resolve(outDir, "pr-readiness-report.md"); + writeFileSync(jsonFile, `${JSON.stringify(report, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + writeMarkdown(report, markdownFile); + process.stdout.write( + `${JSON.stringify({ status: report.decision.status, jsonFile, markdownFile, summary: report.summary }, null, 2)}\n` + ); + process.exitCode = report.decision.status === "BLOCKED" ? 1 : 0; +} + +try { + main(); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 2; +} diff --git a/scripts/ci/collect-staging-release-evidence.mjs b/scripts/ci/collect-staging-release-evidence.mjs new file mode 100644 index 00000000..6a2976c8 --- /dev/null +++ b/scripts/ci/collect-staging-release-evidence.mjs @@ -0,0 +1,354 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const IMAGE_REFERENCE = /^[a-z0-9./:_-]+@sha256:([a-f0-9]{64})$/; +const COMMIT_SHA = /^[a-f0-9]{40}$/; +const ISO_8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const REQUIRED_ARTIFACTS = [ + "candidate.json", + "trivy.json", + "artifact-evidence.json", + "governance.json", + "staging-deployment.json", + "service-matrix.json", + "postgres-integrity.json", + "resilience.json", + "residency-evidence.json", + "approvals.json", +]; + +function requireCondition(condition, message) { + if (!condition) throw new Error(message); +} + +function readJson(directory, filename) { + const path = resolve(directory, filename); + requireCondition( + existsSync(path), + `${filename}: required source evidence file is missing` + ); + requireCondition( + lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink(), + `${filename}: source evidence must be a regular non-symlink file` + ); + let descriptor; + let content; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + requireCondition( + fstatSync(descriptor).isFile(), + `${filename}: source evidence must be a regular non-symlink file` + ); + content = readFileSync(descriptor); + } catch (error) { + if (error instanceof Error && error.message.includes("regular non-symlink")) { + throw error; + } + throw new Error(`${filename}: source evidence must be a regular non-symlink file`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + try { + return { path, content, value: JSON.parse(content.toString("utf8")) }; + } catch (error) { + throw new Error( + `${filename}: must be valid JSON (${error instanceof Error ? error.message : String(error)})` + ); + } +} + +function sha256(content) { + return createHash("sha256").update(content).digest("hex"); +} + +function isEvidenceObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function digestFromImage(image) { + const match = String(image ?? "").match(IMAGE_REFERENCE); + requireCondition( + match, + "candidate.json: image must be an immutable lower-case OCI sha256 reference" + ); + return match[1]; +} + +function validateEvidence(sourceDirectory) { + const records = Object.fromEntries( + REQUIRED_ARTIFACTS.map(filename => { + const record = readJson(sourceDirectory, filename); + return [filename, record]; + }) + ); + for (const filename of REQUIRED_ARTIFACTS) { + requireCondition( + isEvidenceObject(records[filename].value), + `${filename}: must be a JSON object` + ); + } + const candidate = records["candidate.json"].value; + const digest = digestFromImage(candidate.image); + requireCondition( + COMMIT_SHA.test(candidate.sourceCommit ?? ""), + "candidate.json: sourceCommit must be a full lower-case Git SHA" + ); + const builtAt = candidate.builtAt; + const builtAtEpoch = typeof builtAt === "string" ? Date.parse(builtAt) : NaN; + requireCondition( + typeof builtAt === "string" && + ISO_8601_UTC.test(builtAt) && + Number.isFinite(builtAtEpoch) && + new Date(builtAtEpoch).toISOString() === builtAt, + "candidate.json: builtAt must be a canonical ISO-8601 UTC timestamp" + ); + + const trivy = records["trivy.json"].value; + requireCondition( + trivy.scanner === "trivy" && trivy.mode === "direct-image", + "trivy.json: must be a direct-image Trivy result" + ); + requireCondition( + trivy.scanTarget === candidate.image, + "trivy.json: scanTarget must equal candidate image" + ); + requireCondition( + Array.isArray(trivy.results), + "trivy.json: results must be an array" + ); + const highCritical = trivy.results.flatMap((result, index) => { + requireCondition( + isEvidenceObject(result) && Array.isArray(result.vulnerabilities), + `trivy.json: results[${index}].vulnerabilities must be an array` + ); + return result.vulnerabilities; + }).filter(finding => ["HIGH", "CRITICAL"].includes(finding?.severity)); + requireCondition( + highCritical.length === 0, + `trivy.json: found ${highCritical.length} HIGH/CRITICAL finding(s)` + ); + + const artifact = records["artifact-evidence.json"].value; + for (const name of ["sbom", "provenance", "cosign"]) { + const record = artifact?.[name]; + requireCondition( + record?.imageDigest === digest, + `artifact-evidence.json: ${name} is not bound to candidate digest` + ); + requireCondition( + record?.verified === true, + `artifact-evidence.json: ${name} is not marked verified` + ); + requireCondition( + typeof record?.uri === "string" && record.uri.length > 0, + `artifact-evidence.json: ${name} URI is required` + ); + requireCondition( + typeof record?.sha256 === "string" && + /^[a-f0-9]{64}$/.test(record.sha256), + `artifact-evidence.json: ${name} SHA-256 is required` + ); + } + requireCondition( + ["CycloneDX", "SPDX"].includes(artifact?.sbom?.format), + "artifact-evidence.json: SBOM format must be CycloneDX or SPDX" + ); + requireCondition( + artifact?.provenance?.format === "SLSA", + "artifact-evidence.json: provenance format must be SLSA" + ); + requireCondition( + artifact?.cosign?.certificateIdentity?.includes( + ".github/workflows/ci.yml@refs/heads/production" + ), + "artifact-evidence.json: Cosign identity must bind to production workflow" + ); + + const governance = records["governance.json"].value; + requireCondition( + governance.branch === "production", + "governance.json: must describe production" + ); + requireCondition( + governance.requiredApprovals >= 2 && + governance.requireCodeOwnerReviews === true, + "governance.json: two approvals and CODEOWNER review are required" + ); + requireCondition( + governance.productionReleaseEnvironment?.protected === true && + governance.productionReleaseEnvironment?.preventSelfReview === true, + "governance.json: protected production-release environment with self-review denial is required" + ); + + const staging = records["staging-deployment.json"].value; + requireCondition( + staging.status === "passed" && staging.protectedEnvironment === true, + "staging-deployment.json: successful protected staging deployment is required" + ); + requireCondition( + staging.deploymentDigest === digest, + "staging-deployment.json: deployment digest must equal candidate digest" + ); + requireCondition( + staging.mtlsContract === "passed" && + staging.authorizationNegativePath === "passed", + "staging-deployment.json: mTLS and authorization-negative-path results must pass" + ); + + const serviceMatrix = records["service-matrix.json"].value; + const services = Array.isArray(serviceMatrix.services) + ? serviceMatrix.services + : []; + requireCondition( + services.length === 11, + "service-matrix.json: exactly 11 core-service records are required" + ); + for (const service of services) { + requireCondition( + service?.status === "passed" && service?.digest === digest, + `service-matrix.json: ${service?.name ?? "unknown"} is not passed on candidate digest` + ); + requireCondition( + typeof service?.evidenceUri === "string" && + service.evidenceUri.length > 0, + `service-matrix.json: ${service?.name ?? "unknown"} lacks evidenceUri` + ); + } + + requireCondition( + records["postgres-integrity.json"].value.status === "passed", + "postgres-integrity.json: must be passed" + ); + requireCondition( + records["resilience.json"].value.status === "passed", + "resilience.json: must be passed" + ); + requireCondition( + records["residency-evidence.json"].value.status === "passed", + "residency-evidence.json: must be passed" + ); + const approvals = records["approvals.json"].value; + requireCondition( + approvals.candidateDigest === digest, + "approvals.json: candidateDigest must equal candidate digest" + ); + + return { candidate, digest, records }; +} + +export function collectStagingReleaseEvidence({ + sourceDirectory, + outputDirectory, + write = false, +}) { + const source = resolve(sourceDirectory); + requireCondition( + existsSync(source) && + statSync(source).isDirectory() && + !lstatSync(source).isSymbolicLink(), + "source directory must exist and must not be a symbolic link" + ); + const evidence = validateEvidence(source); + const manifest = { + status: write ? "collected" : "planned", + sourceDirectory: source, + outputDirectory: resolve(outputDirectory), + candidate: evidence.candidate, + files: REQUIRED_ARTIFACTS.map(filename => ({ + filename, + sha256: sha256(evidence.records[filename].content), + })), + integrityNotice: + "This bundle copies validated upstream evidence; it does not perform deployments, scans, signatures, approvals, or compliance determinations.", + }; + + if (!write) return manifest; + const output = resolve(outputDirectory); + requireCondition( + !existsSync(output) || !lstatSync(output).isSymbolicLink(), + "output directory must not be a symbolic link" + ); + requireCondition( + !existsSync(output) || readdirSync(output).length === 0, + "output directory must be empty to prevent mixing evidence runs" + ); + mkdirSync(output, { recursive: true, mode: 0o700 }); + requireCondition( + statSync(output).isDirectory() && !lstatSync(output).isSymbolicLink(), + "output directory must be a regular directory" + ); + for (const { filename } of manifest.files) { + writeFileSync(resolve(output, filename), evidence.records[filename].content, { + mode: 0o600, + }); + } + writeFileSync( + resolve(output, "evidence-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o600 } + ); + return manifest; +} + +export function parseCollectorArguments(argumentsList) { + const options = { write: false }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + const value = argumentsList[index + 1]; + if (argument === "--source-dir" && value) { + options.sourceDirectory = value; + index += 1; + } else if (argument === "--out-dir" && value) { + options.outputDirectory = value; + index += 1; + } else if (argument === "--write") { + options.write = true; + } else if (argument === "--confirm" && value) { + options.confirmation = value; + index += 1; + } else { + throw new Error( + "Usage: collect-staging-release-evidence.mjs --source-dir --out-dir [--write --confirm COLLECT_REAL_EVIDENCE]" + ); + } + } + requireCondition( + options.sourceDirectory && options.outputDirectory, + "--source-dir and --out-dir are required" + ); + requireCondition( + !options.write || options.confirmation === "COLLECT_REAL_EVIDENCE", + "--write requires --confirm COLLECT_REAL_EVIDENCE" + ); + return options; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + const result = collectStagingReleaseEvidence( + parseCollectorArguments(process.argv.slice(2)) + ); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/ci/generate-break-glass-evidence.mjs b/scripts/ci/generate-break-glass-evidence.mjs new file mode 100644 index 00000000..7f15092d --- /dev/null +++ b/scripts/ci/generate-break-glass-evidence.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node +/** + * Create a tamper-evident emergency-release evidence pack. + * + * The caller must already have independently verified the candidate digest's + * Trivy, SBOM, Cosign, and GitHub provenance evidence. This command checks the + * digest binding and creates a hash-chained audit ledger. It never publishes or + * deploys an image. + */ +import { createHash } from "node:crypto"; +import { lstat, mkdir, open, readdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { sha256, stableStringify } from "./verify-break-glass-authorization.mjs"; + +const SHA256 = /^sha256:[a-f0-9]{64}$/; +const COMMIT_SHA = /^[a-f0-9]{40}$/; +const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function parseCanonicalTimestamp(value, label) { + assert(typeof value === "string" && ISO_UTC.test(value), `${label} must be a canonical UTC timestamp`); + const parsed = new Date(value); + assert(!Number.isNaN(parsed.getTime()) && parsed.toISOString() === value, `${label} must be a canonical UTC timestamp`); + return parsed; +} + +function requireObject(value, label) { + assert(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be a JSON object`); + return value; +} + +async function readRegularBuffer(path, label) { + const stat = await lstat(path); + assert(stat.isFile() && !stat.isSymbolicLink(), `${label} must be a regular non-symlink file`); + const handle = await open(path, "r"); + try { + const fromHandle = await handle.stat(); + assert(fromHandle.isFile(), `${label} must be a regular file`); + return await handle.readFile(); + } finally { + await handle.close(); + } +} + +async function readJson(path, label) { + const raw = await readRegularBuffer(path, label); + try { + return { raw, value: JSON.parse(raw.toString("utf8")), sha256: sha256(raw) }; + } catch { + throw new Error(`${label} is not valid JSON`); + } +} + +function eventHash(event) { + return sha256(stableStringify(event)); +} + +export function buildAuditEvents(context) { + const rows = []; + let previousEventHash = "sha256:" + "0".repeat(64); + const append = (eventType, data) => { + const event = { + schemaVersion: "ndsep.break-glass-audit-event.v1", + sequence: rows.length + 1, + eventType, + occurredAt: context.generatedAt, + workflow: context.workflow, + candidate: context.candidate, + authorization: context.authorization, + data, + previousEventHash, + }; + const hash = eventHash(event); + const row = { ...event, eventHash: hash }; + rows.push(row); + previousEventHash = hash; + }; + append("break_glass.authorization_verified", { + incidentId: context.authorization.incidentId, + authorizationSha256: context.authorization.sha256, + approvalActors: context.authorization.approvalActors, + expiresAt: context.authorization.expiresAt, + }); + append("break_glass.candidate_revalidated", { + releaseEvidenceSha256: context.releaseEvidence.sha256, + trivyHighCriticalCount: context.releaseEvidence.highCriticalCount, + sbomSha256: context.releaseEvidence.sbomSha256, + }); + append("break_glass.supply_chain_verified", { + cosignVerificationSha256: context.cosignVerification.sha256, + provenanceVerificationSha256: context.provenanceVerification.sha256, + }); + append("break_glass.exception_consumed", { + scopeServices: context.authorization.scopeServices, + operation: "emergency-security-hotfix", + noReadinessCredit: true, + }); + return { rows, rootHash: previousEventHash }; +} + +function parseArgs(argv) { + const accepted = new Set([ + "authorization-verification", "release-evidence", "cosign-verification", + "provenance-verification", "repository", "source-commit", "candidate-digest", + "run-id", "actor", "workflow-ref", "generated-at", "out-dir", + ]); + const values = Object.create(null); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + assert(token.startsWith("--"), `Unexpected argument: ${token}`); + const name = token.slice(2); + assert(accepted.has(name), `Unexpected argument: --${name}`); + assert(values[name] === undefined, `Duplicate argument: --${name}`); + const value = argv[index + 1]; + assert(value && !value.startsWith("--"), `Argument --${name} requires a value`); + values[name] = value; + index += 1; + } + for (const required of accepted) assert(values[required], `Missing required argument: --${required}`); + assert(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(values.repository), "--repository is invalid"); + assert(COMMIT_SHA.test(values["source-commit"]), "--source-commit must be a full lowercase SHA-1"); + assert(SHA256.test(values["candidate-digest"]), "--candidate-digest must be a lowercase sha256 digest"); + assert(/^[1-9][0-9]*$/.test(values["run-id"]), "--run-id must be a positive integer"); + assert(/^[a-z0-9](?:[a-z0-9-]{0,37})$/.test(values.actor), "--actor is invalid"); + assert(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/workflows\/.+@refs\/heads\/production$/.test(values["workflow-ref"]), + "--workflow-ref must identify a production-branch GitHub Actions workflow"); + parseCanonicalTimestamp(values["generated-at"], "--generated-at"); + return values; +} + +async function assertEmptyOutputDirectory(outDir) { + try { + const stat = await lstat(outDir); + assert(stat.isDirectory() && !stat.isSymbolicLink(), "Break-glass evidence output directory must be a real directory"); + assert((await readdir(outDir)).length === 0, "Break-glass evidence output directory must be empty"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + await mkdir(outDir, { recursive: true, mode: 0o700 }); + } +} + +function validateAuthorizationVerification(value, expected) { + requireObject(value, "Authorization verification"); + assert(value.schemaVersion === "ndsep.break-glass-authorization-verification.v1", "Authorization verification schema is unsupported"); + assert(value.repository === expected.repository.toLowerCase(), "Authorization verification repository mismatch"); + const candidate = requireObject(value.authorization?.candidate, "Authorization verification candidate"); + assert(candidate.digest === expected.digest, "Authorization verification candidate digest mismatch"); + assert(candidate.sourceCommit === expected.sourceCommit, "Authorization verification source commit mismatch"); + assert(Array.isArray(value.independentApprovals) && value.independentApprovals.length === 3, + "Authorization verification must contain three independent approvals"); + const roles = new Set(value.independentApprovals.map(approval => approval.role)); + const actors = new Set(value.independentApprovals.map(approval => approval.actor)); + assert(roles.size === 3 && actors.size === 3, "Authorization verification approvals must have three distinct roles and actors"); + assert(value.noReadinessCredit === true, "Authorization verification must preserve no-readiness-credit semantics"); + return { + id: value.authorization.id, + sha256: value.authorization.sha256, + incidentId: value.authorization.incidentId, + expiresAt: value.authorization.expiresAt, + scopeServices: value.authorization.scope.services, + approvalActors: [...actors].sort(), + }; +} + +function validateReleaseEvidence(value, expected) { + requireObject(value, "Release evidence"); + assert(value.schema === "ndsep.release-evidence.v1", "Release evidence schema is unsupported"); + assert(value.image?.name === `ghcr.io/${expected.repository.toLowerCase()}`, "Release evidence image name mismatch"); + assert(value.image?.digest === expected.digest, "Release evidence digest mismatch"); + assert(value.source_sha === expected.sourceCommit, "Release evidence source commit mismatch"); + assert(value.trivy?.high_critical_count === 0, "Release evidence contains HIGH/CRITICAL findings"); + assert(SHA256.test(value.sbom?.sha256), "Release evidence SBOM hash is invalid"); + return { highCriticalCount: value.trivy.high_critical_count, sbomSha256: value.sbom.sha256 }; +} + +async function writePrivateJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); +} + +async function writePrivateBuffer(path, value) { + await writeFile(path, value, { mode: 0o600, flag: "wx" }); +} + +export async function generateBreakGlassEvidence(args) { + await assertEmptyOutputDirectory(args["out-dir"]); + const expected = { + repository: args.repository, + sourceCommit: args["source-commit"], + digest: args["candidate-digest"], + }; + const [{ raw: authorizationVerificationRaw, value: authorizationVerification, sha256: authorizationVerificationSha256 }, { raw: releaseEvidenceRaw, value: releaseEvidence, sha256: releaseEvidenceSha256 }, cosignRaw, provenanceRaw] = await Promise.all([ + readJson(args["authorization-verification"], "Authorization verification"), + readJson(args["release-evidence"], "Release evidence"), + readRegularBuffer(args["cosign-verification"], "Cosign verification"), + readRegularBuffer(args["provenance-verification"], "GitHub provenance verification"), + ]); + assert(cosignRaw.length > 0, "Cosign verification is empty"); + assert(provenanceRaw.length > 0, "GitHub provenance verification is empty"); + const authorization = validateAuthorizationVerification(authorizationVerification, expected); + const release = validateReleaseEvidence(releaseEvidence, expected); + const generatedAt = parseCanonicalTimestamp(args["generated-at"], "--generated-at").toISOString(); + const context = { + generatedAt, + workflow: { + runId: Number(args["run-id"]), + actor: args.actor, + workflowRef: args["workflow-ref"], + }, + candidate: { + image: `ghcr.io/${args.repository.toLowerCase()}`, + digest: expected.digest, + sourceCommit: expected.sourceCommit, + }, + authorization, + releaseEvidence: { sha256: releaseEvidenceSha256, ...release }, + cosignVerification: { sha256: sha256(cosignRaw) }, + provenanceVerification: { sha256: sha256(provenanceRaw) }, + }; + const authorizationCopyPath = resolve(args["out-dir"], "break-glass-authorization-verification.json"); + const releaseEvidenceCopyPath = resolve(args["out-dir"], "break-glass-release-evidence.json"); + const cosignCopyPath = resolve(args["out-dir"], "break-glass-cosign-verification.json"); + const provenanceCopyPath = resolve(args["out-dir"], "break-glass-github-provenance-verification.txt"); + await writePrivateBuffer(authorizationCopyPath, authorizationVerificationRaw); + await writePrivateBuffer(releaseEvidenceCopyPath, releaseEvidenceRaw); + await writePrivateBuffer(cosignCopyPath, cosignRaw); + await writePrivateBuffer(provenanceCopyPath, provenanceRaw); + + const ledger = buildAuditEvents(context); + const auditPath = resolve(args["out-dir"], "break-glass-audit-events.ndjson"); + const auditContents = `${ledger.rows.map(row => JSON.stringify(row)).join("\n")}\n`; + await writeFile(auditPath, auditContents, { encoding: "utf8", mode: 0o600, flag: "wx" }); + const manifest = { + schemaVersion: "ndsep.break-glass-evidence-manifest.v1", + generatedAt, + repository: args.repository.toLowerCase(), + workflow: context.workflow, + candidate: context.candidate, + authorization: { + ...authorization, + verificationSha256: authorizationVerificationSha256, + }, + artifacts: { + authorizationVerification: { path: "break-glass-authorization-verification.json", sha256: authorizationVerificationSha256 }, + releaseEvidence: { path: "break-glass-release-evidence.json", sha256: releaseEvidenceSha256 }, + cosignVerification: { path: "break-glass-cosign-verification.json", ...context.cosignVerification }, + provenanceVerification: { path: "break-glass-github-provenance-verification.txt", ...context.provenanceVerification }, + auditEvents: { path: "break-glass-audit-events.ndjson", sha256: sha256(auditContents), rootHash: ledger.rootHash, count: ledger.rows.length }, + }, + integrity: { + algorithm: "sha256", + attestationRequired: true, + externalImmutableRetentionRequired: true, + noReadinessCredit: true, + }, + }; + const manifestPath = resolve(args["out-dir"], "break-glass-evidence-manifest.json"); + await writePrivateJson(manifestPath, manifest); + const manifestContents = await readRegularBuffer(manifestPath, "Break-glass evidence manifest"); + const root = { + schemaVersion: "ndsep.break-glass-evidence-root.v1", + generatedAt, + candidate: context.candidate, + authorization: { id: authorization.id, sha256: authorization.sha256 }, + manifest: { path: "break-glass-evidence-manifest.json", sha256: sha256(manifestContents) }, + audit: { path: "break-glass-audit-events.ndjson", sha256: sha256(auditContents), rootHash: ledger.rootHash }, + noReadinessCredit: true, + }; + const rootPath = resolve(args["out-dir"], "break-glass-evidence-root.json"); + await writePrivateJson(rootPath, root); + return { auditPath, manifestPath, rootPath, manifest, root }; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const result = await generateBreakGlassEvidence(parseArgs(process.argv.slice(2))); + console.log(JSON.stringify({ + status: "generated", + manifest: result.manifestPath, + root: result.rootPath, + candidate: result.root.candidate, + }, null, 2)); + } catch (error) { + console.error(`Break-glass evidence generation failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} + +export { parseArgs }; diff --git a/scripts/ci/normalize-release-evidence.mjs b/scripts/ci/normalize-release-evidence.mjs new file mode 100644 index 00000000..b980d2a1 --- /dev/null +++ b/scripts/ci/normalize-release-evidence.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const IMAGE_REFERENCE = /^[a-z0-9./:_-]+@sha256:([a-f0-9]{64})$/; +const COMMIT_SHA = /^[a-f0-9]{40}$/; +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + +function requireCondition(condition, message) { + if (!condition) throw new Error(message); +} + +function readJson(path, label) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + throw new Error( + `${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function digestFromImage(image) { + const match = image.match(IMAGE_REFERENCE); + requireCondition( + match, + "image must be a lower-case immutable OCI sha256 reference" + ); + return match[1]; +} + +function relativeUri(uri, label) { + requireCondition( + typeof uri === "string" && uri.length > 0, + `${label} URI is required` + ); + return uri; +} + +function normalizeTrivy(raw, image, trivyUri, trivyPath) { + const rawResults = Array.isArray(raw?.Results) ? raw.Results : []; + const results = rawResults.map(result => ({ + target: typeof result?.Target === "string" ? result.Target : "unknown", + class: typeof result?.Class === "string" ? result.Class : "unknown", + vulnerabilities: (Array.isArray(result?.Vulnerabilities) + ? result.Vulnerabilities + : [] + ).map(finding => ({ + vulnerabilityId: finding?.VulnerabilityID ?? "unknown", + severity: finding?.Severity ?? "UNKNOWN", + packageName: finding?.PkgName ?? "unknown", + installedVersion: finding?.InstalledVersion ?? "unknown", + fixedVersion: finding?.FixedVersion ?? null, + })), + })); + const blocked = results + .flatMap(result => result.vulnerabilities) + .filter(finding => ["HIGH", "CRITICAL"].includes(finding.severity)); + requireCondition( + blocked.length === 0, + `Trivy report contains ${blocked.length} HIGH/CRITICAL finding(s)` + ); + + return { + scanner: "trivy", + mode: "direct-image", + scanTarget: image, + rawReport: { + uri: relativeUri(trivyUri, "Trivy report"), + sha256: sha256(trivyPath), + }, + results, + }; +} + +function normalizeEvidence({ + image, + digest, + sbomPath, + sbomUri, + provenancePath, + provenanceUri, + cosignPath, + cosignUri, + certificateIdentity, +}) { + const sbom = readJson(sbomPath, "CycloneDX SBOM"); + requireCondition( + sbom?.bomFormat === "CycloneDX", + "SBOM must preserve bomFormat: CycloneDX" + ); + requireCondition( + typeof sbom?.specVersion === "string" && sbom.specVersion.length > 0, + "CycloneDX SBOM specVersion is required" + ); + + const cosign = readJson(cosignPath, "Cosign verification"); + requireCondition( + Array.isArray(cosign) && cosign.length > 0, + "Cosign verification must contain at least one verified signature" + ); + const expectedManifestDigest = `sha256:${digest}`; + const digestMatches = cosign.some( + entry => + entry?.critical?.image?.["docker-manifest-digest"] === + expectedManifestDigest + ); + requireCondition( + digestMatches, + "Cosign verification is not bound to the candidate digest" + ); + + const provenance = readFileSync(provenancePath, "utf8"); + requireCondition( + provenance.trim().length > 0, + "GitHub provenance verification output is empty" + ); + + return { + sbom: { + imageDigest: digest, + verified: true, + uri: relativeUri(sbomUri, "SBOM"), + sha256: sha256(sbomPath), + format: "CycloneDX", + specVersion: sbom.specVersion, + }, + provenance: { + imageDigest: digest, + verified: true, + uri: relativeUri(provenanceUri, "Provenance"), + sha256: sha256(provenancePath), + format: "SLSA", + }, + cosign: { + imageDigest: digest, + verified: true, + uri: relativeUri(cosignUri, "Cosign verification"), + sha256: sha256(cosignPath), + certificateIdentity, + }, + }; +} + +export function normalizeReleaseEvidence(options) { + const image = options.image; + const digest = digestFromImage(image); + requireCondition( + COMMIT_SHA.test(options.sourceCommit ?? ""), + "sourceCommit must be a full lower-case commit SHA" + ); + requireCondition( + ISO_TIMESTAMP.test(options.builtAt ?? "") && + !Number.isNaN(Date.parse(options.builtAt)), + "builtAt must be an ISO-8601 UTC timestamp" + ); + requireCondition( + typeof options.certificateIdentity === "string" && + options.certificateIdentity.includes( + ".github/workflows/ci.yml@refs/heads/production" + ), + "certificate identity must bind to the production release workflow" + ); + + const rawTrivy = readJson(options.trivyPath, "Trivy report"); + const trivy = normalizeTrivy( + rawTrivy, + image, + options.trivyUri, + options.trivyPath + ); + const artifactEvidence = normalizeEvidence({ + image, + digest, + sbomPath: options.sbomPath, + sbomUri: options.sbomUri, + provenancePath: options.provenancePath, + provenanceUri: options.provenanceUri, + cosignPath: options.cosignPath, + cosignUri: options.cosignUri, + certificateIdentity: options.certificateIdentity, + }); + + return { + candidate: { + image, + sourceCommit: options.sourceCommit, + builtAt: options.builtAt, + }, + trivy, + artifactEvidence, + }; +} + +function parseArguments(argumentsList) { + const values = {}; + for (let index = 0; index < argumentsList.length; index += 2) { + const option = argumentsList[index]; + const value = argumentsList[index + 1]; + requireCondition( + option?.startsWith("--") && value, + "Usage: normalize-release-evidence.mjs --image --source-commit --built-at --trivy --trivy-uri --sbom --sbom-uri --provenance --provenance-uri --cosign --cosign-uri --certificate-identity --out-dir " + ); + values[option.slice(2)] = value; + } + for (const required of [ + "image", + "source-commit", + "built-at", + "trivy", + "trivy-uri", + "sbom", + "sbom-uri", + "provenance", + "provenance-uri", + "cosign", + "cosign-uri", + "certificate-identity", + "out-dir", + ]) { + requireCondition(values[required], `Missing required --${required} option`); + } + return { + image: values.image, + sourceCommit: values["source-commit"], + builtAt: values["built-at"], + trivyPath: values.trivy, + trivyUri: values["trivy-uri"], + sbomPath: values.sbom, + sbomUri: values["sbom-uri"], + provenancePath: values.provenance, + provenanceUri: values["provenance-uri"], + cosignPath: values.cosign, + cosignUri: values["cosign-uri"], + certificateIdentity: values["certificate-identity"], + outputDirectory: values["out-dir"], + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + const options = parseArguments(process.argv.slice(2)); + const result = normalizeReleaseEvidence(options); + mkdirSync(options.outputDirectory, { recursive: true }); + for (const [filename, record] of Object.entries({ + "candidate.json": result.candidate, + "trivy.json": result.trivy, + "artifact-evidence.json": result.artifactEvidence, + })) { + writeFileSync( + resolve(options.outputDirectory, filename), + `${JSON.stringify(record, null, 2)}\n`, + { mode: 0o600 } + ); + } + console.log( + JSON.stringify( + { status: "passed", outputDirectory: resolve(options.outputDirectory) }, + null, + 2 + ) + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/ci/publish-break-glass-alerts.mjs b/scripts/ci/publish-break-glass-alerts.mjs new file mode 100644 index 00000000..6e75424e --- /dev/null +++ b/scripts/ci/publish-break-glass-alerts.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/** + * Render or deliver sanitized break-glass alert events after offline evidence + * verification. The default is dry-run; --deliver requires an explicit protected + * environment confirmation and both SIEM + PagerDuty credentials. + */ +import { lstat, mkdir, open, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { sha256 } from "./verify-break-glass-authorization.mjs"; +import { verifyBreakGlassEvidence } from "./verify-break-glass-evidence.mjs"; + +export const SIEM_MODES = new Set(["splunk-hec", "elastic"]); +export const DELIVERY_CONFIRMATION = "DELIVER_SANITIZED_BREAK_GLASS_EVENTS"; +const DEFAULT_ALERT_DELIVERY_POLICY = ".github/security/break-glass-alert-delivery-policy.json"; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function parseHttpsUrl(value, label) { + assert(typeof value === "string" && value.length > 0, `${label} is required`); + let parsed; + try { parsed = new URL(value); } catch { throw new Error(`${label} must be an HTTPS URL`); } + assert(parsed.protocol === "https:" && !parsed.username && !parsed.password && !parsed.search && !parsed.hash, + `${label} must be a credential-free HTTPS URL without query or fragment`); + return parsed; +} + +export async function readAlertDeliveryPolicy(path, verified) { + const stat = await lstat(path); + assert(stat.isFile() && !stat.isSymbolicLink(), "Break-glass alert delivery policy must be a regular non-symlink file"); + const handle = await open(path, "r"); + let raw; + try { + const opened = await handle.stat(); + assert(opened.isFile(), "Break-glass alert delivery policy must be a regular file"); + raw = await handle.readFile(); + } finally { + await handle.close(); + } + let policy; + try { policy = JSON.parse(raw.toString("utf8")); } catch { throw new Error("Break-glass alert delivery policy is not valid JSON"); } + assert(policy !== null && typeof policy === "object" && !Array.isArray(policy), "Break-glass alert delivery policy must be a JSON object"); + assert(policy.schemaVersion === "ndsep.break-glass-alert-delivery-policy.v1", "Break-glass alert delivery policy schema is unsupported"); + assert(policy.repository === verified.repository, "Break-glass alert delivery policy repository mismatch"); + assert(policy.environment === "production-release", "Break-glass alert delivery policy environment mismatch"); + assert(policy.enabled === true, "Break-glass alert delivery is disabled until accountable maintainers enable the reviewed policy"); + assert(Array.isArray(policy.allowedSiemModes) && policy.allowedSiemModes.every(mode => SIEM_MODES.has(mode)), + "Break-glass alert delivery policy SIEM modes are invalid"); + assert(policy.pagerDutyEndpoint === "https://events.pagerduty.com/v2/enqueue", "Break-glass alert delivery policy PagerDuty endpoint is invalid"); + assert(policy.attestationRequired === true && policy.externalImmutableRetentionRequired === true && policy.noReadinessCredit === true, + "Break-glass alert delivery policy must preserve attestation, retention, and no-readiness requirements"); + return policy; +} + +export function nonSecretEnvironment(name) { + const value = process.env[name]; + assert(typeof value === "string" && value.length > 0, `${name} is required`); + return value; +} + +export function normalizeElasticIndex(value) { + assert(/^[a-z0-9][a-z0-9._-]{0,254}$/.test(value), "BREAK_GLASS_ELASTIC_INDEX is invalid"); + assert(!value.includes(".."), "BREAK_GLASS_ELASTIC_INDEX is invalid"); + return value; +} + +function compactSha(value) { + return value.replace(/^sha256:/, ""); +} + +export function buildSanitizedAlert(verified) { + const eventId = sha256(`${verified.rootSha256}\n${verified.auditRootHash}`); + const workflowRunUrl = `https://github.com/${verified.repository}/actions/runs/${verified.workflow.runId}`; + return { + schemaVersion: "ndsep.break-glass-siem-event.v1", + eventId, + eventType: "break_glass.exception_consumed", + severity: "critical", + timestamp: verified.generatedAt, + repository: verified.repository, + incidentId: verified.authorization.incidentId, + authorizationId: verified.authorization.id, + authorizationSha256: verified.authorization.sha256, + authorizationExpiresAt: verified.authorization.expiresAt, + candidate: verified.candidate, + evidence: { + rootSha256: verified.rootSha256, + manifestSha256: verified.manifestSha256, + auditRootHash: verified.auditRootHash, + artifactDigests: verified.artifactDigests, + attestationVerificationRequired: true, + externalImmutableRetentionRequired: true, + }, + audit: { + eventTypes: verified.verifiedEventTypes, + noReadinessCredit: true, + }, + deliveryScope: "break-glass authorization evidence only; no deployment performed", + links: { + workflowRun: workflowRunUrl, + }, + }; +} + +export function buildSplunkPayload(alert, endpoint, index) { + const target = parseHttpsUrl(endpoint, "BREAK_GLASS_SIEM_ENDPOINT"); + assert(/\/services\/collector\/event\/?$/.test(target.pathname), + "Splunk HEC endpoint must end with /services/collector/event"); + assert(/^[a-z0-9][a-z0-9_-]{0,79}$/i.test(index), "BREAK_GLASS_SPLUNK_INDEX is invalid"); + return { + endpoint: target.toString(), + headers: { Authorization: `Splunk ${nonSecretEnvironment("BREAK_GLASS_SIEM_TOKEN")}` }, + body: { + time: Date.parse(alert.timestamp) / 1000, + host: "ndsep-github-actions", + source: "ndsep.break-glass", + sourcetype: "ndsep:break_glass:v1", + index, + event: alert, + }, + }; +} + +export function buildElasticPayload(alert, endpoint, index) { + const target = parseHttpsUrl(endpoint, "BREAK_GLASS_SIEM_ENDPOINT"); + assert(target.pathname === "/" || target.pathname === "", "Elasticsearch endpoint must be an origin without a path"); + const documentId = compactSha(alert.eventId); + target.pathname = `/${encodeURIComponent(index)}/_create/${documentId}`; + return { + endpoint: target.toString(), + headers: { Authorization: `ApiKey ${nonSecretEnvironment("BREAK_GLASS_SIEM_TOKEN")}` }, + body: alert, + }; +} + +export function buildPagerDutyPayload(alert, routingKey) { + assert(/^[a-zA-Z0-9]{32}$/.test(routingKey), "PAGERDUTY_ROUTING_KEY must be a 32-character integration key"); + const dedupKey = `ndsep-break-glass-${alert.authorizationId}-${compactSha(alert.candidate.digest).slice(0, 24)}`; + assert(dedupKey.length <= 255, "PagerDuty deduplication key is too long"); + return { + routing_key: routingKey, + event_action: "trigger", + dedup_key: dedupKey, + client: "NDSEP protected release", + client_url: `https://github.com/${alert.repository}`, + payload: { + summary: `NDSEP break-glass authorization ${alert.authorizationId} requires SOC review`, + source: `github-actions:${alert.repository}`, + severity: "critical", + timestamp: alert.timestamp, + component: "break-glass-authorization", + group: "production-release", + class: "schema-drift-emergency-exception", + custom_details: { + event_id: alert.eventId, + incident_id: alert.incidentId, + authorization_id: alert.authorizationId, + authorization_expires_at: alert.authorizationExpiresAt, + candidate_digest: alert.candidate.digest, + source_commit: alert.candidate.sourceCommit, + evidence_root_sha256: alert.evidence.rootSha256, + audit_root_hash: alert.evidence.auditRootHash, + no_readiness_credit: true, + deployment_performed: false, + }, + }, + links: [{ href: alert.links.workflowRun, text: "Protected authorization workflow" }], + }; +} + +export async function postJson(fetchImpl, endpoint, headers, body, label) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + redirect: "error", + signal: controller.signal, + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + assert(response.ok, `${label} delivery failed with HTTP ${response.status}`); + return { status: response.status }; + } finally { + clearTimeout(timeout); + } +} + +function parseArgs(argv) { + const values = { deliver: false }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--deliver") { + assert(values.deliver === false, "Duplicate argument: --deliver"); + values.deliver = true; + continue; + } + assert(["--evidence-dir", "--out-dir"].includes(token), `Unexpected argument: ${token}`); + const value = argv[index + 1]; + assert(value && !value.startsWith("--"), `Argument ${token} requires a value`); + const key = token.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + assert(values[key] === undefined, `Duplicate argument: ${token}`); + values[key] = value; + index += 1; + } + assert(values.evidenceDir && values.outDir, "usage: publish-break-glass-alerts.mjs --evidence-dir --out-dir [--deliver]"); + return values; +} + +export async function publishBreakGlassAlerts(args, options = {}) { + const verified = await verifyBreakGlassEvidence(args.evidenceDir); + const alert = buildSanitizedAlert(verified); + const mode = process.env.BREAK_GLASS_SIEM_MODE; + const delivery = { + schemaVersion: "ndsep.break-glass-alert-delivery.v1", + generatedAt: new Date().toISOString(), + eventId: alert.eventId, + evidenceRootSha256: verified.rootSha256, + auditRootHash: verified.auditRootHash, + mode: args.deliver ? "deliver" : "dry-run", + siem: { configured: false, delivered: false }, + pagerDuty: { configured: false, delivered: false }, + noReadinessCredit: true, + deploymentPerformed: false, + }; + let siemPayload; + let pagerPayload; + if (args.deliver) { + assert(process.env.BREAK_GLASS_DELIVERY_CONFIRMATION === DELIVERY_CONFIRMATION, + "BREAK_GLASS_DELIVERY_CONFIRMATION must explicitly authorize sanitized alert delivery"); + assert(SIEM_MODES.has(mode), "BREAK_GLASS_SIEM_MODE must be splunk-hec or elastic"); + const policy = await readAlertDeliveryPolicy(process.env.BREAK_GLASS_ALERT_DELIVERY_POLICY ?? DEFAULT_ALERT_DELIVERY_POLICY, verified); + assert(policy.allowedSiemModes.includes(mode), "Break-glass alert delivery policy does not permit the requested SIEM mode"); + const endpoint = nonSecretEnvironment("BREAK_GLASS_SIEM_ENDPOINT"); + siemPayload = mode === "splunk-hec" + ? buildSplunkPayload(alert, endpoint, process.env.BREAK_GLASS_SPLUNK_INDEX ?? "ndsep_security") + : buildElasticPayload(alert, endpoint, normalizeElasticIndex(process.env.BREAK_GLASS_ELASTIC_INDEX ?? "ndsep-break-glass")); + pagerPayload = buildPagerDutyPayload(alert, nonSecretEnvironment("PAGERDUTY_ROUTING_KEY")); + delivery.siem = { configured: true, delivered: false, mode }; + delivery.pagerDuty = { configured: true, delivered: false }; + const fetchImpl = options.fetchImpl ?? fetch; + const siemResult = await postJson(fetchImpl, siemPayload.endpoint, siemPayload.headers, siemPayload.body, "SIEM"); + delivery.siem = { configured: true, delivered: true, mode, status: siemResult.status }; + const pagerResult = await postJson(fetchImpl, policy.pagerDutyEndpoint, {}, pagerPayload, "PagerDuty"); + delivery.pagerDuty = { configured: true, delivered: true, status: pagerResult.status }; + } else if (mode && !SIEM_MODES.has(mode)) { + throw new Error("BREAK_GLASS_SIEM_MODE must be splunk-hec or elastic when set"); + } + await mkdir(args.outDir, { recursive: true, mode: 0o700 }); + const receiptPath = resolve(args.outDir, "break-glass-alert-delivery-receipt.json"); + assert(dirname(receiptPath) === resolve(args.outDir), "Delivery receipt path escaped its output directory"); + await writeFile(receiptPath, `${JSON.stringify({ delivery, alertPreview: alert }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + return { delivery, alert, receiptPath, siemPayload, pagerPayload }; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const result = await publishBreakGlassAlerts(parseArgs(process.argv.slice(2))); + console.log(JSON.stringify({ status: result.delivery.mode, receipt: result.receiptPath, eventId: result.delivery.eventId }, null, 2)); + } catch (error) { + console.error(`Break-glass alert publication failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/ci/publish-break-glass-tamper-alert.mjs b/scripts/ci/publish-break-glass-tamper-alert.mjs new file mode 100644 index 00000000..cfe92c60 --- /dev/null +++ b/scripts/ci/publish-break-glass-tamper-alert.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/** + * Publish a sanitized alert when a protected break-glass evidence verification + * fails. Default mode is dry-run; live delivery is disabled by source policy + * and requires explicit protected-environment confirmation. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { sha256 } from "./verify-break-glass-authorization.mjs"; +import { + DELIVERY_CONFIRMATION, + SIEM_MODES, + buildElasticPayload, + buildSplunkPayload, + nonSecretEnvironment, + normalizeElasticIndex, + postJson, + readAlertDeliveryPolicy, +} from "./publish-break-glass-alerts.mjs"; + +const DEFAULT_ALERT_DELIVERY_POLICY = ".github/security/break-glass-alert-delivery-policy.json"; +const DELIVERY_SCOPE = "break-glass evidence-tamper detection only; no deployment performed"; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function cleanErrorCode(value) { + assert(typeof value === "string" && /^[a-z0-9][a-z0-9._-]{2,95}$/.test(value), + "--failure-code must be a lowercase sanitized error code"); + return value; +} + +function cleanRepository(value) { + assert(typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value), "--repository is invalid"); + return value.toLowerCase(); +} + +function cleanCommit(value) { + assert(/^[a-f0-9]{40}$/.test(value ?? ""), "--source-commit must be a full lowercase SHA-1"); + return value; +} + +function cleanDigest(value) { + assert(/^sha256:[a-f0-9]{64}$/.test(value ?? ""), "--candidate-digest must be a lowercase sha256 digest"); + return value; +} + +function cleanRunId(value) { + assert(/^[1-9][0-9]*$/.test(value ?? ""), "--run-id must be a positive integer"); + return value; +} + +export function buildTamperDetectionAlert(args) { + const repository = cleanRepository(args.repository); + const sourceCommit = cleanCommit(args.sourceCommit); + const candidateDigest = cleanDigest(args.candidateDigest); + const workflowRunId = cleanRunId(args.runId); + const failureCode = cleanErrorCode(args.failureCode); + const eventId = sha256(`ndsep.break-glass-tamper-detection.v1\n${repository}\n${sourceCommit}\n${candidateDigest}\n${workflowRunId}\n${failureCode}`); + return { + schemaVersion: "ndsep.break-glass-siem-event.v1", + eventId, + eventType: "break_glass.evidence_tamper_detected", + severity: "critical", + timestamp: args.occurredAt, + repository, + candidate: { + image: `ghcr.io/${repository}`, + digest: candidateDigest, + sourceCommit, + }, + tamperDetection: { + failureCode, + verificationWorkflowRunId: workflowRunId, + attestationVerificationRequired: true, + externalImmutableRetentionRequired: true, + }, + audit: { noReadinessCredit: true }, + deliveryScope: DELIVERY_SCOPE, + links: { workflowRun: `https://github.com/${repository}/actions/runs/${workflowRunId}` }, + }; +} + +function buildTamperPagerDutyPayload(alert, routingKey) { + assert(/^[a-zA-Z0-9]{32}$/.test(routingKey), "PAGERDUTY_ROUTING_KEY must be a 32-character integration key"); + const dedupKey = `ndsep-break-glass-tamper-${alert.tamperDetection.verificationWorkflowRunId}-${alert.eventId.slice(7, 31)}`; + return { + routing_key: routingKey, + event_action: "trigger", + dedup_key: dedupKey, + client: "NDSEP protected release", + client_url: `https://github.com/${alert.repository}`, + payload: { + summary: `NDSEP break-glass evidence tamper detection requires SOC review`, + source: `github-actions:${alert.repository}`, + severity: "critical", + timestamp: alert.timestamp, + component: "break-glass-evidence-verification", + group: "production-release", + class: "tamper-detection", + custom_details: { + event_id: alert.eventId, + failure_code: alert.tamperDetection.failureCode, + verification_workflow_run_id: alert.tamperDetection.verificationWorkflowRunId, + candidate_digest: alert.candidate.digest, + source_commit: alert.candidate.sourceCommit, + no_readiness_credit: true, + deployment_performed: false, + }, + }, + links: [{ href: alert.links.workflowRun, text: "Protected emergency workflow" }], + }; +} + +function parseArgs(argv) { + const accepted = new Set(["repository", "source-commit", "candidate-digest", "run-id", "failure-code", "out-dir"]); + const result = { deliver: false }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--deliver") { + assert(result.deliver === false, "Duplicate argument: --deliver"); + result.deliver = true; + continue; + } + assert(token.startsWith("--") && accepted.has(token.slice(2)), `Unexpected argument: ${token}`); + const key = token.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + const value = argv[index + 1]; + assert(value && !value.startsWith("--"), `Argument ${token} requires a value`); + assert(result[key] === undefined, `Duplicate argument: ${token}`); + result[key] = value; + index += 1; + } + for (const key of accepted) assert(result[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())], `Missing required argument: --${key}`); + return result; +} + +export async function publishBreakGlassTamperAlert(args, options = {}) { + const alert = buildTamperDetectionAlert({ ...args, occurredAt: args.occurredAt ?? new Date().toISOString() }); + const receipt = { + schemaVersion: "ndsep.break-glass-tamper-alert-receipt.v1", + generatedAt: new Date().toISOString(), + eventId: alert.eventId, + eventType: alert.eventType, + failureCode: alert.tamperDetection.failureCode, + mode: args.deliver ? "deliver" : "dry-run", + siem: { configured: false, delivered: false }, + pagerDuty: { configured: false, delivered: false }, + noReadinessCredit: true, + deploymentPerformed: false, + }; + if (args.deliver) { + assert(process.env.BREAK_GLASS_DELIVERY_CONFIRMATION === DELIVERY_CONFIRMATION, + "BREAK_GLASS_DELIVERY_CONFIRMATION must explicitly authorize sanitized alert delivery"); + const mode = process.env.BREAK_GLASS_SIEM_MODE; + assert(SIEM_MODES.has(mode), "BREAK_GLASS_SIEM_MODE must be splunk-hec or elastic"); + const policy = await readAlertDeliveryPolicy(process.env.BREAK_GLASS_ALERT_DELIVERY_POLICY ?? DEFAULT_ALERT_DELIVERY_POLICY, { repository: alert.repository }); + assert(policy.allowedSiemModes.includes(mode), "Break-glass alert delivery policy does not permit the requested SIEM mode"); + const endpoint = nonSecretEnvironment("BREAK_GLASS_SIEM_ENDPOINT"); + const siemPayload = mode === "splunk-hec" + ? buildSplunkPayload(alert, endpoint, process.env.BREAK_GLASS_SPLUNK_INDEX ?? "ndsep_security") + : buildElasticPayload(alert, endpoint, normalizeElasticIndex(process.env.BREAK_GLASS_ELASTIC_INDEX ?? "ndsep-break-glass")); + const pagerPayload = buildTamperPagerDutyPayload(alert, nonSecretEnvironment("PAGERDUTY_ROUTING_KEY")); + const fetchImpl = options.fetchImpl ?? fetch; + receipt.siem = { configured: true, delivered: false, mode }; + receipt.pagerDuty = { configured: true, delivered: false }; + const siemResult = await postJson(fetchImpl, siemPayload.endpoint, siemPayload.headers, siemPayload.body, "SIEM tamper alert"); + receipt.siem = { configured: true, delivered: true, mode, status: siemResult.status }; + const pagerResult = await postJson(fetchImpl, policy.pagerDutyEndpoint, {}, pagerPayload, "PagerDuty tamper alert"); + receipt.pagerDuty = { configured: true, delivered: true, status: pagerResult.status }; + } + await mkdir(args.outDir, { recursive: true, mode: 0o700 }); + const receiptPath = resolve(args.outDir, "break-glass-tamper-alert-receipt.json"); + assert(dirname(receiptPath) === resolve(args.outDir), "Tamper alert receipt path escaped its output directory"); + await writeFile(receiptPath, `${JSON.stringify({ receipt, alert }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + return { receipt, alert, receiptPath }; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const args = parseArgs(process.argv.slice(2)); + const result = await publishBreakGlassTamperAlert(args); + console.log(JSON.stringify({ status: result.receipt.mode, receipt: result.receiptPath, eventId: result.receipt.eventId }, null, 2)); + } catch (error) { + console.error(`Break-glass tamper alert publication failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} + +export { parseArgs }; diff --git a/scripts/ci/remind-visual-baseline-reviewers.mjs b/scripts/ci/remind-visual-baseline-reviewers.mjs new file mode 100644 index 00000000..2441c430 --- /dev/null +++ b/scripts/ci/remind-visual-baseline-reviewers.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * Confirmation-gated reviewer reminder for the visual baseline decision set. + * + * Default mode is read-only planning. Send mode creates a single PR issue + * comment that mentions only already-requested, independent reviewers. It does + * not create approvals, alter review decisions, change snapshots, merge, or + * modify branch/environment policies. + * + * Usage: + * node scripts/ci/remind-visual-baseline-reviewers.mjs \ + * --repo owner/repo --pr 18 --reviewers login1,login2,login3,login4 --mode plan + * + * node scripts/ci/remind-visual-baseline-reviewers.mjs \ + * --repo owner/repo --pr 18 --reviewers login1,login2,login3,login4 \ + * --mode send --confirm SEND_VISUAL_BASELINE_REVIEW_REMINDER + */ +import { execFileSync } from "node:child_process"; + +const FULL_SHA = /^[a-f0-9]{40}$/i; +const LOGIN = /^[A-Za-z0-9-]+$/; +const CONFIRMATION = "SEND_VISUAL_BASELINE_REVIEW_REMINDER"; +const REQUIRED_REVIEWERS = 4; +const REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000; + +function fail(message) { + throw new Error(message); +} + +function arg(args, name) { + const position = args.indexOf(name); + return position < 0 ? undefined : args[position + 1]; +} + +function readGh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", GH_FORCE_TTY: "0" }, + }).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); + } catch (error) { + const detail = + error?.stderr?.toString().trim() || error?.message || String(error); + fail(`GitHub read failed: ${detail}`); + } +} + +function writeGh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", GH_FORCE_TTY: "0" }, + }).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); + } catch (error) { + const detail = + error?.stderr?.toString().trim() || error?.message || String(error); + fail(`GitHub reminder send failed: ${detail}`); + } +} + +function parseReviewers(raw) { + const reviewers = [ + ...new Set( + String(raw ?? "") + .split(",") + .map(value => value.trim()) + .filter(Boolean) + ), + ]; + if ( + reviewers.length !== REQUIRED_REVIEWERS || + reviewers.some(value => !LOGIN.test(value)) + ) { + fail( + `--reviewers must contain exactly ${REQUIRED_REVIEWERS} distinct GitHub logins` + ); + } + return reviewers.sort(); +} + +function marker(head, reviewers) { + return ``; +} + +function buildBody(pr, reviewers, reminderMarker) { + const mentions = reviewers.map(login => `@${login}`).join(" "); + return `${reminderMarker}\n\n${mentions}\n\n## Independent visual-baseline review requested\n\nPR #${pr.number} is blocked because all **12 Chromium visual baselines** require a recorded independent decision at the current source head \`${pr.headRefOid}\`. Please review the retained candidate capture set and, if acceptable, record your role-specific acceptance in \`e2e/visual-baseline-review.json\` with each PNG SHA-256 and rationale.\n\nRequired roles are product/UX owner, QA lead, accessibility owner, and independent engineering reviewer. Each reviewer must submit a GitHub **APPROVED** review at this exact source head. Do not approve if any capture does not meet product, accessibility, or regression expectations; request a functional correction and a fresh candidate instead.\n\nThis reminder does not approve a baseline, merge the PR, change snapshot tolerances, alter retry policy, or authorize deployment.`; +} + +function main() { + const args = process.argv.slice(2); + const repo = arg(args, "--repo"); + const prNumber = Number(arg(args, "--pr")); + const mode = arg(args, "--mode") ?? "plan"; + const reviewers = parseReviewers(arg(args, "--reviewers")); + if ( + !repo || + !/^[\w.-]+\/[\w.-]+$/.test(repo) || + !Number.isInteger(prNumber) || + prNumber < 1 || + !["plan", "send"].includes(mode) + ) { + fail( + "Usage: --repo owner/repo --pr positive-integer --reviewers four-logins --mode plan|send [--confirm SEND_VISUAL_BASELINE_REVIEW_REMINDER]" + ); + } + if (mode === "send" && arg(args, "--confirm") !== CONFIRMATION) + fail(`--confirm must equal ${CONFIRMATION} for send mode`); + + const fields = + "number,url,author,headRefOid,reviewDecision,mergeStateStatus,reviewRequests"; + const pr = JSON.parse( + readGh(["pr", "view", String(prNumber), "--repo", repo, "--json", fields]) + ); + if (!FULL_SHA.test(pr.headRefOid ?? "")) + fail("pull request has no full source head SHA"); + if (reviewers.includes(pr.author?.login)) + fail("the PR author cannot receive an independent-review reminder"); + + const requested = new Set( + (pr.reviewRequests ?? []).map(entry => entry?.login).filter(Boolean) + ); + const unassigned = reviewers.filter(login => !requested.has(login)); + if (unassigned.length) + fail( + `refusing to notify unassigned reviewer(s): ${unassigned.join(", ")}; request review through the authorized governance process first` + ); + + const reminderMarker = marker(pr.headRefOid, reviewers); + const comments = JSON.parse( + readGh(["api", `repos/${repo}/issues/${prNumber}/comments?per_page=100`]) + ); + const recentMatching = comments.find( + comment => + typeof comment?.body === "string" && + comment.body.includes(reminderMarker) && + Date.now() - Date.parse(comment.created_at) < REMINDER_WINDOW_MS + ); + const body = buildBody(pr, reviewers, reminderMarker); + const plan = { + status: mode === "plan" ? "PLAN_READY" : "SENT", + repository: repo, + pullRequest: { + number: pr.number, + url: pr.url, + head: pr.headRefOid, + author: pr.author.login, + reviewDecision: pr.reviewDecision, + mergeStateStatus: pr.mergeStateStatus, + }, + reviewers, + reminderMarker, + deduplicated: Boolean(recentMatching), + proposedBody: body, + }; + + if (mode === "plan" || recentMatching) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + return; + } + + const response = JSON.parse( + writeGh([ + "api", + "--method", + "POST", + `repos/${repo}/issues/${prNumber}/comments`, + "-f", + `body=${body}`, + ]) + ); + process.stdout.write( + `${JSON.stringify({ ...plan, commentUrl: response.html_url, commentId: response.id }, null, 2)}\n` + ); +} + +try { + main(); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; +} diff --git a/scripts/ci/render-webhook-delivery-worker-manifest.sh b/scripts/ci/render-webhook-delivery-worker-manifest.sh new file mode 100755 index 00000000..7a7e4fe2 --- /dev/null +++ b/scripts/ci/render-webhook-delivery-worker-manifest.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +template="$repo_root/infra/k8s/webhook-delivery-worker.yaml.tmpl" +output="${1:-${NDSEP_WEBHOOK_DELIVERY_MANIFEST_OUTPUT:-}}" +image="${NDSEP_WEBHOOK_DELIVERY_IMAGE:-}" + +if [[ -z "$output" ]]; then + echo "usage: NDSEP_WEBHOOK_DELIVERY_IMAGE=ghcr.io/munisp/ndsep@sha256:<64-hex> $0 /absolute/path/to/rendered.yaml" >&2 + exit 64 +fi +if [[ "$output" != /* ]]; then + echo "output path must be absolute" >&2 + exit 64 +fi +if [[ ! "$image" =~ ^ghcr\.io/munisp/ndsep@sha256:[a-f0-9]{64}$ ]]; then + echo "NDSEP_WEBHOOK_DELIVERY_IMAGE must be an approved immutable ghcr.io/munisp/ndsep@sha256:<64-lowercase-hex> reference" >&2 + exit 64 +fi +if [[ ! -f "$template" ]]; then + echo "deployment template is missing: $template" >&2 + exit 66 +fi + +mkdir -p "$(dirname "$output")" +sed "s|\${NDSEP_WEBHOOK_DELIVERY_IMAGE}|${image}|g" "$template" > "$output" +if grep -Fq '${NDSEP_WEBHOOK_DELIVERY_IMAGE}' "$output"; then + echo "unresolved deployment image token" >&2 + exit 65 +fi + +grep -Fq "image: $image" "$output" || { + echo "rendered manifest does not contain the approved immutable image reference" >&2 + exit 65 +} +printf 'rendered_webhook_delivery_worker_manifest=%s\n' "$output" diff --git a/scripts/ci/run-isolated-visual-regression.sh b/scripts/ci/run-isolated-visual-regression.sh new file mode 100755 index 00000000..8517fbff --- /dev/null +++ b/scripts/ci/run-isolated-visual-regression.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Run the NDSEP Chromium visual suite from a detached source-commit worktree +# against a disposable localhost PostgreSQL database and isolated app port. +# This diagnostic script never passes --update-snapshots, creates no review or +# approval, and removes any Playwright candidate files with its worktree. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +if [[ -n "$(git status --porcelain)" ]]; then + printf 'refusing to run from a dirty checkout; commit or discard local changes first\n' >&2 + exit 65 +fi + +source_commit="$(git rev-parse HEAD)" +git cat-file -e "${source_commit}^{commit}" + +port="${NDSEP_VISUAL_PORT:-3101}" +if ! [[ "$port" =~ ^[0-9]{2,5}$ ]] || ((port < 1024 || port > 65535)); then + printf 'NDSEP_VISUAL_PORT must be an unprivileged TCP port (1024-65535)\n' >&2 + exit 64 +fi +if netstat -ltn 2>/dev/null | awk -v port=":${port}" '$4 ~ (port "$") { found=1 } END { exit(found ? 0 : 1) }'; then + printf 'NDSEP_VISUAL_PORT=%s is already listening; choose an unused isolated port\n' "$port" >&2 + exit 69 +fi + +run_nonce="$(date +%s)_$$" +role="ndsep_visual_${run_nonce}" +database="ndsep_visual_${run_nonce}" +password="ndsep_visual_local_only_${run_nonce}" +base_url="http://127.0.0.1:${port}" +output_dir="${NDSEP_VISUAL_OUTPUT_DIR:-$(mktemp -d /tmp/ndsep-visual-regression.XXXXXX)}" +worktree_parent="$(mktemp -d /tmp/ndsep-visual-worktree.XXXXXX)" +worktree="$worktree_parent/checkout" +server_pid="" +worktree_created=false + +if [[ "$output_dir" != /tmp/* && "$output_dir" != /home/ubuntu/* ]]; then + printf 'NDSEP_VISUAL_OUTPUT_DIR must be under /tmp or /home/ubuntu\n' >&2 + exit 64 +fi +mkdir -p "$output_dir" +printf '%s\n' "$source_commit" >"$output_dir/source-commit.txt" +printf '%s\n' "$base_url" >"$output_dir/base-url.txt" + +cleanup() { + local exit_status=$? + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + sudo -u postgres psql -v ON_ERROR_STOP=1 </dev/null 2>&1 || true +SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${database}' AND pid <> pg_backend_pid(); +DROP DATABASE IF EXISTS ${database}; +DROP ROLE IF EXISTS ${role}; +SQL + if [[ "$worktree_created" == true ]]; then + git -C "$repo_root" worktree remove --force "$worktree" >/dev/null 2>&1 || true + fi + rm -rf "$worktree_parent" + printf 'diagnostic_output=%s\n' "$output_dir" >&2 + exit "$exit_status" +} +trap cleanup EXIT INT TERM + +if ! sudo -u postgres psql -Atqc 'SELECT 1' >/dev/null; then + printf 'local PostgreSQL superuser access is required for isolated visual diagnostics\n' >&2 + exit 69 +fi + +sudo -u postgres psql -v ON_ERROR_STOP=1 <"$output_dir/worktree.log" 2>&1 +worktree_created=true + +export DATABASE_URL="postgresql://${role}:${password}@127.0.0.1:5432/${database}" +export NODE_ENV=test +export SKIP_DATABASE_MIGRATIONS=true +export JWT_SECRET=test-jwt-secret-for-isolated-visual-diagnostics-only +export FIELD_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef +export PORT="$port" +export BASE_URL="$base_url" +export E2E_BASE_URL="$base_url" +export CI=1 +export PLAYWRIGHT_JSON_OUTPUT_NAME="$output_dir/test-results.json" + +cd "$worktree" +pnpm install --frozen-lockfile --ignore-scripts >"$output_dir/pnpm-install.log" 2>&1 +pnpm exec drizzle-kit push --force >"$output_dir/migration.log" 2>&1 +pnpm exec playwright install --with-deps chromium >"$output_dir/playwright-browser-install.log" 2>&1 +pnpm run build >"$output_dir/build.log" 2>&1 +node dist/index.js >"$output_dir/server.log" 2>&1 & +server_pid=$! +printf '%s\n' "$server_pid" >"$output_dir/server.pid" + +healthy=false +for attempt in {1..30}; do + if curl --fail --silent --show-error "$base_url/api/health" >"$output_dir/health.json"; then + healthy=true + break + fi + sleep 2 +done +if [[ "$healthy" != true ]]; then + printf 'isolated application did not become healthy; see %s/server.log\n' "$output_dir" >&2 + exit 1 +fi + +set +e +pnpm exec playwright test e2e/visual-regression.spec.ts \ + --project=chromium \ + --workers=1 \ + --retries=0 \ + --timeout=60000 \ + --reporter=line,json \ + --output="$output_dir/playwright-output" \ + >"$output_dir/playwright.log" 2>&1 +test_status=$? +set -e +printf '%s\n' "$test_status" >"$output_dir/playwright-exit-status.txt" + +if [[ ! -f "$output_dir/test-results.json" ]]; then + printf 'Playwright did not write the expected JSON result: %s/test-results.json\n' "$output_dir" >&2 + exit 1 +fi + +if [[ "$test_status" -eq 0 ]]; then + printf 'isolated_visual_status=passed\n' +else + printf 'isolated_visual_status=failed\n' >&2 +fi +exit "$test_status" diff --git a/scripts/ci/staging-schema-drift-and-synthetic-fixture-verification.sql b/scripts/ci/staging-schema-drift-and-synthetic-fixture-verification.sql new file mode 100644 index 00000000..82b3a12b --- /dev/null +++ b/scripts/ci/staging-schema-drift-and-synthetic-fixture-verification.sql @@ -0,0 +1,314 @@ +-- NDSEP staging schema-drift and synthetic-fixture verification query pack. +-- +-- RUN AS: a read-only staging database role, after the guarded +-- `pnpm seed:remaining` command has succeeded. +-- DO NOT use this file to load fixtures. Apart from the read-only transaction +-- boundary, it contains SELECT and SHOW statements only. +-- +-- Correct zero-drift protocol: +-- 1. Apply the same immutable commit's Drizzle journal to a clean +-- disposable baseline database. +-- 2. Run the schema fingerprint sections in this file against both the +-- baseline and staging database. +-- 3. Require identical fingerprints and zero rows from every mismatch +-- query. A fingerprint from staging alone cannot prove zero drift. + +BEGIN TRANSACTION READ ONLY; + +-- 1. Confirm this is a read-only verification session. Review the returned +-- database name/host/role manually; do not use a production/live target. +SHOW transaction_read_only; +SELECT + current_database() AS database_name, + current_user AS database_role, + current_setting('server_version_num') AS server_version_num, + inet_server_addr()::text AS server_address, + inet_server_port() AS server_port, + current_setting('search_path') AS search_path; + +-- 2. Active migration journal health. Drizzle records migration hashes/times, +-- not source file tags, so use this as a count/history health check and the +-- schema-contract queries below to prove that the 0035 objects exist. +SELECT + COUNT(*) AS applied_drizzle_migrations, + MIN(created_at) AS earliest_migration_at, + MAX(created_at) AS latest_migration_at, + COUNT(DISTINCT hash) AS distinct_migration_hashes +FROM drizzle.__drizzle_migrations; + +-- 3. Required table-existence check. ZERO rows is required. +WITH expected(table_name) AS ( + VALUES + ('analytics_snapshots'), + ('article40_codes'), + ('dpco_audit_service_records'), + ('dpco_registry_service_records'), + ('dpco_verification_service_records'), + ('dt_jurisdictions'), + ('dt_policies'), + ('dt_org_agents'), + ('dt_simulations'), + ('dt_simulation_results'), + ('dt_monte_carlo_stats'), + ('dt_policy_impacts'), + ('dt_sandboxes'), + ('marketplace_plugins'), + ('mobile_push_devices'), + ('noc_agent_memory'), + ('onboarding_checklists'), + ('users') +) +SELECT expected.table_name AS missing_required_table +FROM expected +LEFT JOIN information_schema.tables actual + ON actual.table_schema = 'public' AND actual.table_name = expected.table_name +WHERE actual.table_name IS NULL +ORDER BY expected.table_name; + +-- 4. Required active-schema columns for the fixture extension. ZERO rows is +-- required. This is a targeted contract for the extension; compare the full +-- schema fingerprints below for platform-wide drift. +WITH expected(table_name, column_name) AS ( + VALUES + ('analytics_snapshots', 'metric_name'), ('analytics_snapshots', 'dimension'), + ('analytics_snapshots', 'dimension_value'), ('analytics_snapshots', 'metric_value'), + ('analytics_snapshots', 'snapshot_date'), ('analytics_snapshots', 'metadata'), + ('article40_codes', 'code_name'), ('article40_codes', 'sector'), + ('article40_codes', 'description'), ('article40_codes', 'submitted_by'), + ('article40_codes', 'document_url'), ('article40_codes', 'status'), + ('dpco_audit_service_records', 'audit_id'), ('dpco_audit_service_records', 'payload'), + ('dpco_registry_service_records', 'registry_id'), ('dpco_registry_service_records', 'payload'), + ('dpco_verification_service_records', 'statement_id'), ('dpco_verification_service_records', 'payload'), + ('dt_jurisdictions', 'code'), ('dt_jurisdictions', 'name'), + ('dt_policies', 'jurisdiction_id'), ('dt_policies', 'code'), ('dt_policies', 'rules'), + ('dt_org_agents', 'jurisdiction_id'), ('dt_org_agents', 'org_name'), + ('dt_simulations', 'simulation_id'), ('dt_simulations', 'jurisdictions'), + ('dt_simulation_results', 'simulation_id'), ('dt_simulation_results', 'jurisdiction'), + ('dt_monte_carlo_stats', 'simulation_id'), ('dt_monte_carlo_stats', 'metric'), + ('dt_policy_impacts', 'simulation_id'), ('dt_policy_impacts', 'policy_id'), + ('dt_sandboxes', 'sandbox_id'), ('dt_sandboxes', 'base_snapshot'), + ('marketplace_plugins', 'name'), ('marketplace_plugins', 'manifest'), + ('mobile_push_devices', 'user_id'), ('mobile_push_devices', 'token'), ('mobile_push_devices', 'device_id'), + ('noc_agent_memory', 'memory_id'), ('noc_agent_memory', 'context'), + ('onboarding_checklists', 'user_id'), ('onboarding_checklists', 'step_id'), + ('users', 'open_id'), ('users', 'role') +) +SELECT expected.table_name, expected.column_name AS missing_required_column +FROM expected +LEFT JOIN information_schema.columns actual + ON actual.table_schema = 'public' + AND actual.table_name = expected.table_name + AND actual.column_name = expected.column_name +WHERE actual.column_name IS NULL +ORDER BY expected.table_name, expected.column_name; + +-- 5. Every foreign key and check constraint in public must be validated. +-- ZERO rows is required unless a separately approved historical remediation +-- explicitly documents a NOT VALID constraint. +SELECT + conrelid::regclass::text AS table_name, + conname AS constraint_name, + CASE contype WHEN 'f' THEN 'foreign_key' WHEN 'c' THEN 'check' ELSE contype::text END AS constraint_type +FROM pg_constraint +WHERE connamespace = 'public'::regnamespace + AND contype IN ('f', 'c') + AND NOT convalidated +ORDER BY table_name, constraint_name; + +-- 6. No invalid or not-ready public index may exist. ZERO rows is required. +SELECT + indexes.indexrelid::regclass::text AS index_name, + indexes.indrelid::regclass::text AS table_name, + indexes.indisvalid, + indexes.indisready, + indexes.indisunique, + indexes.indisprimary +FROM pg_index AS indexes +JOIN pg_class AS relation ON relation.oid = indexes.indrelid +JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace +WHERE namespace.nspname = 'public' + AND (NOT indexes.indisvalid OR NOT indexes.indisready) +ORDER BY table_name, index_name; + +-- 7. Schema fingerprints. Run this exact section in clean baseline and +-- staging. Require the schema_column_fingerprint and schema_constraint_fingerprint +-- results to match exactly. Hashes do not disclose row data. +WITH columns_contract AS ( + SELECT + table_schema, table_name, ordinal_position, column_name, data_type, udt_schema, + udt_name, is_nullable, COALESCE(column_default, '') AS column_default + FROM information_schema.columns + WHERE table_schema = 'public' +), canonical AS ( + SELECT string_agg( + format('%s.%s#%s:%s:%s.%s:%s:%s:%s', + table_schema, table_name, ordinal_position, column_name, data_type, + udt_schema, udt_name, is_nullable, column_default + ), + E'\n' ORDER BY table_schema, table_name, ordinal_position + ) AS contents + FROM columns_contract +) +SELECT md5(COALESCE(contents, '')) AS schema_column_fingerprint +FROM canonical; + +WITH constraint_contract AS ( + SELECT + conrelid::regclass::text AS table_name, + conname, + contype, + condeferrable, + condeferred, + convalidated, + pg_get_constraintdef(oid, true) AS definition + FROM pg_constraint + WHERE connamespace = 'public'::regnamespace +), canonical AS ( + SELECT string_agg( + format('%s:%s:%s:%s:%s:%s:%s', table_name, conname, contype, condeferrable, condeferred, convalidated, definition), + E'\n' ORDER BY table_name, conname + ) AS contents + FROM constraint_contract +) +SELECT md5(COALESCE(contents, '')) AS schema_constraint_fingerprint +FROM canonical; + +WITH index_contract AS ( + SELECT + schemaname, tablename, indexname, indexdef + FROM pg_indexes + WHERE schemaname = 'public' +), canonical AS ( + SELECT string_agg( + format('%s.%s:%s:%s', schemaname, tablename, indexname, indexdef), + E'\n' ORDER BY schemaname, tablename, indexname + ) AS contents + FROM index_contract +) +SELECT md5(COALESCE(contents, '')) AS schema_index_fingerprint +FROM canonical; + +-- 8. Exact expected fixture records. All actual_rows must be >= minimum_rows. +-- A 0 count is a failed fixture extension. Counts greater than one should be +-- investigated for idempotency drift before any report is accepted. +WITH fixture_counts AS ( + SELECT 'analytics_snapshots'::text AS fixture_table, 1 AS minimum_rows, + COUNT(*) FILTER (WHERE metric_name = 'synthetic_fixture_coverage' AND dimension = 'scenario' AND dimension_value = '2026-08-31')::bigint AS actual_rows + FROM analytics_snapshots + UNION ALL SELECT 'article40_codes', 1, + COUNT(*) FILTER (WHERE code_name = 'SYNTHETIC-ARTICLE40-DEMO' AND sector = 'synthetic-testing')::bigint FROM article40_codes + UNION ALL SELECT 'dpco_audit_service_records', 1, + COUNT(*) FILTER (WHERE audit_id = '00000000-0000-4000-8000-000000000035'::uuid AND payload ->> 'synthetic' = 'true')::bigint FROM dpco_audit_service_records + UNION ALL SELECT 'dpco_registry_service_records', 1, + COUNT(*) FILTER (WHERE registry_id = '00000000-0000-4000-8000-000000000036'::uuid AND payload ->> 'synthetic' = 'true')::bigint FROM dpco_registry_service_records + UNION ALL SELECT 'dpco_verification_service_records', 1, + COUNT(*) FILTER (WHERE statement_id = '00000000-0000-4000-8000-000000000037'::uuid AND payload ->> 'synthetic' = 'true')::bigint FROM dpco_verification_service_records + UNION ALL SELECT 'dt_org_agents', 1, + COUNT(*) FILTER (WHERE org_name = 'Synthetic Fixture Organization')::bigint FROM dt_org_agents + UNION ALL SELECT 'dt_simulations', 1, + COUNT(*) FILTER (WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001')::bigint FROM dt_simulations + UNION ALL SELECT 'dt_simulation_results', 1, + COUNT(*) FILTER (WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND jurisdiction = 'SYNTH' AND month = 1 AND iteration = 1)::bigint FROM dt_simulation_results + UNION ALL SELECT 'dt_monte_carlo_stats', 1, + COUNT(*) FILTER (WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND jurisdiction = 'SYNTH' AND month = 1 AND metric = 'compliance')::bigint FROM dt_monte_carlo_stats + UNION ALL SELECT 'dt_policy_impacts', 1, + COUNT(*) FILTER (WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND jurisdiction = 'SYNTH' AND sector = 'synthetic-testing')::bigint FROM dt_policy_impacts + UNION ALL SELECT 'dt_sandboxes', 1, + COUNT(*) FILTER (WHERE sandbox_id = 'NDSEP-SYNTHETIC-SANDBOX-001')::bigint FROM dt_sandboxes + UNION ALL SELECT 'marketplace_plugins', 1, + COUNT(*) FILTER (WHERE name = 'Synthetic Fixture Policy Connector' AND manifest ->> 'synthetic' = 'true')::bigint FROM marketplace_plugins + UNION ALL SELECT 'mobile_push_devices', 1, + COUNT(*) FILTER (WHERE user_id = 'synthetic-user-001' AND device_id = 'synthetic-device-001' AND token = 'ndsep-synthetic-token-do-not-use-001')::bigint FROM mobile_push_devices + UNION ALL SELECT 'noc_agent_memory', 1, + COUNT(*) FILTER (WHERE memory_id = 'SYNTHETIC-NOC-MEMORY-001' AND context ->> 'synthetic' = 'true')::bigint FROM noc_agent_memory + UNION ALL SELECT 'onboarding_checklists', 1, + COUNT(*) FILTER ( + WHERE step_id = 'synthetic_fixture_completed' + AND user_id = (SELECT id FROM users WHERE open_id = 'synthetic-onboarding-user-001') + )::bigint FROM onboarding_checklists +) +SELECT fixture_table, minimum_rows, actual_rows, + CASE WHEN actual_rows >= minimum_rows THEN 'PASS' ELSE 'FAIL' END AS result +FROM fixture_counts +ORDER BY fixture_table; + +-- 9. Detect duplicate exact fixture markers. ZERO rows is required. +SELECT fixture_table, actual_rows +FROM ( + SELECT 'analytics_snapshots'::text AS fixture_table, COUNT(*)::bigint AS actual_rows + FROM analytics_snapshots WHERE metric_name = 'synthetic_fixture_coverage' AND dimension = 'scenario' AND dimension_value = '2026-08-31' + UNION ALL SELECT 'article40_codes', COUNT(*)::bigint FROM article40_codes WHERE code_name = 'SYNTHETIC-ARTICLE40-DEMO' AND sector = 'synthetic-testing' + UNION ALL SELECT 'dt_simulations', COUNT(*)::bigint FROM dt_simulations WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' + UNION ALL SELECT 'dt_sandboxes', COUNT(*)::bigint FROM dt_sandboxes WHERE sandbox_id = 'NDSEP-SYNTHETIC-SANDBOX-001' + UNION ALL SELECT 'marketplace_plugins', COUNT(*)::bigint FROM marketplace_plugins WHERE name = 'Synthetic Fixture Policy Connector' + UNION ALL SELECT 'mobile_push_devices', COUNT(*)::bigint FROM mobile_push_devices WHERE user_id = 'synthetic-user-001' AND device_id = 'synthetic-device-001' + UNION ALL SELECT 'noc_agent_memory', COUNT(*)::bigint FROM noc_agent_memory WHERE memory_id = 'SYNTHETIC-NOC-MEMORY-001' +) AS counts +WHERE actual_rows <> 1 +ORDER BY fixture_table; + +-- 10. Fixture-only parent-child relationship integrity. ZERO rows is required. +SELECT issue, child_reference +FROM ( + SELECT 'missing synthetic jurisdiction for organization agent'::text AS issue, agent.org_name AS child_reference + FROM dt_org_agents agent + LEFT JOIN dt_jurisdictions jurisdiction ON jurisdiction.id = agent.jurisdiction_id + WHERE agent.org_name = 'Synthetic Fixture Organization' AND jurisdiction.code <> 'SYNTH' + + UNION ALL + SELECT 'missing synthetic simulation for result', result.simulation_id + FROM dt_simulation_results result + LEFT JOIN dt_simulations simulation ON simulation.simulation_id = result.simulation_id + WHERE result.simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND simulation.simulation_id IS NULL + + UNION ALL + SELECT 'missing synthetic simulation for monte-carlo statistic', statistic.simulation_id + FROM dt_monte_carlo_stats statistic + LEFT JOIN dt_simulations simulation ON simulation.simulation_id = statistic.simulation_id + WHERE statistic.simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND simulation.simulation_id IS NULL + + UNION ALL + SELECT 'missing synthetic simulation or policy for policy impact', impact.simulation_id + FROM dt_policy_impacts impact + LEFT JOIN dt_simulations simulation ON simulation.simulation_id = impact.simulation_id + LEFT JOIN dt_policies policy ON policy.id = impact.policy_id + WHERE impact.simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' + AND (simulation.simulation_id IS NULL OR policy.code <> 'SYNTHETIC-FIXTURE-POLICY-001') + + UNION ALL + SELECT 'missing synthetic user for onboarding checklist', checklist.step_id + FROM onboarding_checklists checklist + LEFT JOIN users user_record ON user_record.id = checklist.user_id + WHERE checklist.step_id = 'synthetic_fixture_completed' + AND (user_record.open_id <> 'synthetic-onboarding-user-001' OR user_record.open_id IS NULL) +) AS integrity_failures +ORDER BY issue, child_reference; + +-- 11. Full public table population. This is the SQL equivalent of the strict +-- `pnpm seed:verify` count pass. Any returned row identifies a still-empty table. +-- ZERO rows is required before claiming full synthetic fixture coverage. The +-- table identifier is escaped with %I before the dynamic read-only count query. +SELECT public_table.tablename AS empty_public_table +FROM pg_tables AS public_table +CROSS JOIN LATERAL ( + SELECT ((xpath( + '/table/row/row_count/text()', + query_to_xml( + format('SELECT COUNT(*) AS row_count FROM public.%I', public_table.tablename), + false, + false, + '' + ) + ))[1]::text)::bigint AS row_count +) AS exact_count +WHERE public_table.schemaname = 'public' + AND exact_count.row_count = 0 +ORDER BY public_table.tablename; + +-- 12. The query pack itself makes no readiness, release, or compliance claim. +SELECT + 'synthetic-fixture-verification-only'::text AS evidence_scope, + false AS grants_readiness_credit, + false AS grants_release_or_compliance_evidence; + +ROLLBACK; diff --git a/scripts/ci/verify-60-readiness-evidence.mjs b/scripts/ci/verify-60-readiness-evidence.mjs new file mode 100644 index 00000000..8391302a --- /dev/null +++ b/scripts/ci/verify-60-readiness-evidence.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const REQUIRED_SERVICES = [ + "postgresql", + "tigerbeetle", + "redis", + "mojaloop", + "kafka", + "apisix", + "keycloak", + "openappsec", + "permify", + "opensearch", + "fluvio", +]; + +const REQUIRED_ACCEPTANCE_ROLES = [ + "release-security", + "sre", + "data-owner", + "compliance-evidence-verifier", + "security", +]; + +const SHA256_IMAGE_REFERENCE = /^[a-z0-9./:_-]+@sha256:([a-f0-9]{64})$/; +const COMMIT_SHA = /^[a-f0-9]{40}$/; + +function readJson(evidenceDir, filename, errors) { + try { + return JSON.parse(readFileSync(resolve(evidenceDir, filename), "utf8")); + } catch (error) { + errors.push(`${filename}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +function requireCondition(errors, condition, message) { + if (!condition) errors.push(message); +} + +function digestFromReference(reference) { + const match = typeof reference === "string" ? reference.match(SHA256_IMAGE_REFERENCE) : null; + return match?.[1] ?? null; +} + +function hasOnlyPassedChecks(record, label, errors) { + requireCondition(errors, Array.isArray(record?.checks) && record.checks.length > 0, `${label}: checks must be a non-empty array`); + for (const check of record?.checks ?? []) { + requireCondition( + errors, + check?.status === "passed", + `${label}: check '${check?.name ?? "unnamed"}' is not passed` + ); + } +} + +function verifyCandidate(candidate, errors) { + const digest = digestFromReference(candidate?.image); + requireCondition(errors, digest !== null, "candidate.json: image must be a lower-case immutable OCI sha256 reference"); + requireCondition(errors, COMMIT_SHA.test(candidate?.sourceCommit ?? ""), "candidate.json: sourceCommit must be a full lower-case commit SHA"); + requireCondition(errors, typeof candidate?.builtAt === "string" && !Number.isNaN(Date.parse(candidate.builtAt)), "candidate.json: builtAt must be an ISO-8601 timestamp"); + return digest; +} + +function verifyTrivy(trivy, digest, errors) { + requireCondition(errors, trivy?.scanTarget?.endsWith(`@sha256:${digest}`), "trivy.json: scanTarget must be the candidate image digest"); + requireCondition(errors, trivy?.scanner === "trivy", "trivy.json: scanner must identify Trivy"); + requireCondition(errors, trivy?.mode === "direct-image", "trivy.json: mode must be direct-image"); + const findings = trivy?.results?.flatMap((result) => result?.vulnerabilities ?? []) ?? []; + const blocked = findings.filter((finding) => ["HIGH", "CRITICAL"].includes(finding?.severity)); + requireCondition(errors, blocked.length === 0, `trivy.json: found ${blocked.length} HIGH/CRITICAL vulnerability finding(s)`); +} + +function verifyArtifactEvidence(artifact, digest, errors) { + for (const [name, report] of Object.entries(artifact ?? {})) { + requireCondition(errors, report?.imageDigest === digest, `artifact-evidence.json: ${name} is not bound to the candidate digest`); + requireCondition(errors, report?.verified === true, `artifact-evidence.json: ${name} is not marked verified`); + requireCondition(errors, typeof report?.uri === "string" && report.uri.length > 0, `artifact-evidence.json: ${name} is missing immutable evidence URI`); + } + requireCondition(errors, ["SPDX", "CycloneDX"].includes(artifact?.sbom?.format), "artifact-evidence.json: an SPDX or CycloneDX SBOM is required"); + requireCondition(errors, artifact?.provenance?.format === "SLSA", "artifact-evidence.json: SLSA provenance is required"); + requireCondition(errors, artifact?.cosign?.certificateIdentity?.includes(".github/workflows/ci.yml@refs/heads/production"), "artifact-evidence.json: cosign certificate identity must bind to the production release workflow"); +} + +function verifyGovernance(governance, errors) { + requireCondition(errors, governance?.branch === "production", "governance.json: snapshot must describe production"); + requireCondition(errors, governance?.requiredApprovals >= 2, "governance.json: production requires at least two approvals"); + requireCondition(errors, governance?.requireCodeOwnerReviews === true, "governance.json: required CODEOWNER review is not enabled"); + requireCondition(errors, governance?.dismissStaleReviews === true, "governance.json: stale reviews are not dismissed"); + requireCondition(errors, governance?.requireLastPushApproval === true, "governance.json: last-push approval is not required"); + requireCondition(errors, governance?.strictStatusChecks === true, "governance.json: strict status checks are not enabled"); + requireCondition(errors, governance?.enforceAdmins === true, "governance.json: administrators can bypass controls"); + requireCondition(errors, governance?.allowForcePushes === false, "governance.json: force pushes are allowed"); + requireCondition(errors, governance?.allowDeletions === false, "governance.json: branch deletion is allowed"); + requireCondition(errors, governance?.codeownersErrors?.length === 0, "governance.json: CODEOWNERS contains unresolved errors"); + requireCondition(errors, governance?.productionReleaseEnvironment?.protected === true, "governance.json: production-release environment is not protected"); + requireCondition(errors, governance?.productionReleaseEnvironment?.preventSelfReview === true, "governance.json: production-release permits self-review"); +} + +function verifyStaging(staging, digest, errors) { + requireCondition(errors, staging?.environment === "staging", "staging-deployment.json: evidence must be from protected staging"); + requireCondition(errors, staging?.protectedEnvironment === true, "staging-deployment.json: staging environment is not protected"); + requireCondition(errors, staging?.deploymentDigest === digest, "staging-deployment.json: deployed digest does not match candidate"); + requireCondition(errors, staging?.status === "passed", "staging-deployment.json: deployment status is not passed"); + requireCondition(errors, staging?.mtlsContract === "passed", "staging-deployment.json: mTLS contract test is not passed"); + requireCondition(errors, staging?.authorizationNegativePath === "passed", "staging-deployment.json: authorization negative-path test is not passed"); + hasOnlyPassedChecks(staging, "staging-deployment.json", errors); +} + +function verifyServices(matrix, digest, errors) { + const rows = new Map((matrix?.services ?? []).map((service) => [service?.name, service])); + for (const name of REQUIRED_SERVICES) { + const service = rows.get(name); + requireCondition(errors, service !== undefined, `service-matrix.json: missing '${name}' evidence`); + requireCondition(errors, service?.status === "passed", `service-matrix.json: ${name} is not passed`); + requireCondition(errors, service?.digest === digest, `service-matrix.json: ${name} is not bound to candidate digest`); + requireCondition(errors, typeof service?.evidenceUri === "string" && service.evidenceUri.length > 0, `service-matrix.json: ${name} is missing evidence URI`); + } +} + +function verifyOperationalEvidence(postgres, resilience, residency, errors) { + for (const [label, record, requiredChecks] of [ + ["postgres-integrity.json", postgres, ["encrypted-backup-restore", "forced-rls-denial", "ledger-recompute", "advisory-lock-concurrency"]], + ["resilience.json", resilience, ["stateful-service-fault-recovery", "alert-escalation", "reconciliation"]], + ["residency-evidence.json", residency, ["signed-evidence-verification", "replay-rejection", "merkle-transparency", "appeal-workflow"]], + ]) { + requireCondition(errors, record?.status === "passed", `${label}: overall status is not passed`); + const names = new Set((record?.checks ?? []).filter((check) => check?.status === "passed").map((check) => check.name)); + for (const check of requiredChecks) requireCondition(errors, names.has(check), `${label}: required check '${check}' is absent or failed`); + } +} + +function verifyAcceptances(approvals, digest, errors) { + requireCondition(errors, approvals?.candidateDigest === digest, "approvals.json: approvals are not bound to candidate digest"); + const roles = new Set((approvals?.acceptances ?? []).filter((acceptance) => acceptance?.decision === "accepted").map((acceptance) => acceptance.role)); + for (const role of REQUIRED_ACCEPTANCE_ROLES) requireCondition(errors, roles.has(role), `approvals.json: missing accepted '${role}' sign-off`); +} + +export function verifyReadiness60Evidence(evidenceDir) { + const errors = []; + const candidate = readJson(evidenceDir, "candidate.json", errors); + const trivy = readJson(evidenceDir, "trivy.json", errors); + const artifacts = readJson(evidenceDir, "artifact-evidence.json", errors); + const governance = readJson(evidenceDir, "governance.json", errors); + const staging = readJson(evidenceDir, "staging-deployment.json", errors); + const services = readJson(evidenceDir, "service-matrix.json", errors); + const postgres = readJson(evidenceDir, "postgres-integrity.json", errors); + const resilience = readJson(evidenceDir, "resilience.json", errors); + const residency = readJson(evidenceDir, "residency-evidence.json", errors); + const approvals = readJson(evidenceDir, "approvals.json", errors); + + const digest = verifyCandidate(candidate, errors); + if (digest) { + verifyTrivy(trivy, digest, errors); + verifyArtifactEvidence(artifacts, digest, errors); + verifyStaging(staging, digest, errors); + verifyServices(services, digest, errors); + verifyAcceptances(approvals, digest, errors); + } + verifyGovernance(governance, errors); + verifyOperationalEvidence(postgres, resilience, residency, errors); + + return { + status: errors.length === 0 ? "passed" : "blocked", + threshold: "60/100", + evidenceDirectory: resolve(evidenceDir), + errors, + }; +} + +function parseArguments(argumentsList) { + const position = argumentsList.indexOf("--evidence-dir"); + if (position === -1 || !argumentsList[position + 1]) { + throw new Error("Usage: node scripts/ci/verify-60-readiness-evidence.mjs --evidence-dir /secure/path/to/evidence"); + } + return argumentsList[position + 1]; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = verifyReadiness60Evidence(parseArguments(process.argv.slice(2))); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.status === "passed" ? 0 : 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } +} diff --git a/scripts/ci/verify-break-glass-authorization.mjs b/scripts/ci/verify-break-glass-authorization.mjs new file mode 100644 index 00000000..fdad656c --- /dev/null +++ b/scripts/ci/verify-break-glass-authorization.mjs @@ -0,0 +1,352 @@ +#!/usr/bin/env node +/** + * Fail-closed verifier for a one-time, digest-bound emergency release authorization. + * + * Authorization record: a marked JSON block in a GitHub incident issue body. + * Independent approvals: marked issue comments from source-controlled role allowlists. + * + * This verifier never publishes an image, changes a deployment, or mutates an issue. + * It writes only a sanitized verification record after all checks pass. + */ +import { createHash } from "node:crypto"; +import { lstat, mkdir, open, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const AUTH_SCHEMA = "ndsep.break-glass-authorization.v1"; +const POLICY_SCHEMA = "ndsep.break-glass-authorization-policy.v1"; +const AUTH_MARKER = ""; +const APPROVAL_MARKER = ""; +const SHA256 = /^sha256:[a-f0-9]{64}$/; +const COMMIT_SHA = /^[a-f0-9]{40}$/; +const FINGERPRINT = /^[a-f0-9]{32}$/; +const GITHUB_LOGIN = /^[a-z0-9](?:[a-z0-9-]{0,37})$/; +const INCIDENT_ID = /^(?:INC|SEC)-[0-9]{4}-[A-Z0-9-]{1,64}$/; +const AUTHORIZATION_ID = /^BG-[0-9]{4}-[A-Z0-9]{4,32}$/; +const SERVICE = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const ALLOWED_MISMATCHES = new Set(["column", "constraint", "index"]); +const REQUIRED_ROLES = ["incident_commander", "release_authority", "database_authority"]; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +export function stableStringify(value) { + if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") { + assert(Number.isFinite(value), "Canonical JSON cannot contain a non-finite number"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + assert(typeof value === "object", "Canonical JSON contains an unsupported value"); + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; +} + +export function sha256(value) { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function parseCanonicalTimestamp(value, label) { + assert(typeof value === "string", `${label} must be an ISO-8601 UTC timestamp`); + const parsed = new Date(value); + assert(!Number.isNaN(parsed.getTime()) && parsed.toISOString() === value, `${label} must be a canonical UTC timestamp`); + return parsed; +} + +function requireObject(value, label) { + assert(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be a JSON object`); + return value; +} + +function requireString(value, label, pattern) { + assert(typeof value === "string" && value.length > 0, `${label} must be a non-empty string`); + if (pattern) assert(pattern.test(value), `${label} is invalid`); + return value; +} + +async function readRegularFile(path, label) { + const stat = await lstat(path); + assert(stat.isFile() && !stat.isSymbolicLink(), `${label} must be a regular non-symlink file`); + const handle = await open(path, "r"); + try { + const statFromHandle = await handle.stat(); + assert(statFromHandle.isFile(), `${label} must be a regular file`); + return await handle.readFile({ encoding: "utf8" }); + } finally { + await handle.close(); + } +} + +export async function readAuthorizationPolicy(path) { + let policy; + try { + policy = JSON.parse(await readRegularFile(path, "Break-glass policy")); + } catch (error) { + throw new Error(`Unable to read break-glass policy: ${error instanceof Error ? error.message : String(error)}`); + } + requireObject(policy, "Break-glass policy"); + assert(policy.schemaVersion === POLICY_SCHEMA, "Break-glass policy schemaVersion is unsupported"); + requireString(policy.policyId, "Break-glass policy policyId", /^[a-z0-9][a-z0-9-]{2,63}$/); + assert(policy.enabled === true, "Break-glass authorization is disabled until accountable approvers are configured and the policy is enabled through protected review"); + requireString(policy.repository, "Break-glass policy repository", /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/); + requireString(policy.environment, "Break-glass policy environment", /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/); + assert(Number.isInteger(policy.maximumAuthorizationMinutes) && policy.maximumAuthorizationMinutes > 0 && policy.maximumAuthorizationMinutes <= 240, + "Break-glass policy maximumAuthorizationMinutes must be an integer from 1 to 240"); + assert(Array.isArray(policy.requiredIssueLabels) && policy.requiredIssueLabels.length >= 2, + "Break-glass policy must require at least two incident labels"); + assert(policy.roles && typeof policy.roles === "object", "Break-glass policy roles are required"); + for (const role of REQUIRED_ROLES) { + const allowed = policy.roles[role]?.allowedLogins; + assert(Array.isArray(allowed) && allowed.length > 0, `Break-glass policy role ${role} must have at least one allowed login`); + for (const login of allowed) { + requireString(login, `Break-glass policy ${role} login`, GITHUB_LOGIN); + assert(!login.includes("replace"), `Break-glass policy ${role} contains a placeholder identity`); + } + } + return policy; +} + +export function extractAuthorizationFromIssueBody(body) { + assert(typeof body === "string" && body.includes(AUTH_MARKER), "Incident issue body is missing the break-glass authorization marker"); + const markerIndex = body.indexOf(AUTH_MARKER); + const afterMarker = body.slice(markerIndex + AUTH_MARKER.length); + const match = afterMarker.match(/^\s*```json\s*\n([\s\S]*?)\n```/); + assert(match, "Incident issue body must place canonical authorization JSON immediately after the marker"); + try { + return JSON.parse(match[1]); + } catch { + throw new Error("Incident issue authorization block is not valid JSON"); + } +} + +export function validateAuthorizationRecord(authorization, expected, policy, now = new Date()) { + requireObject(authorization, "Break-glass authorization"); + assert(authorization.schemaVersion === AUTH_SCHEMA, "Break-glass authorization schemaVersion is unsupported"); + requireString(authorization.authorizationId, "Break-glass authorizationId", AUTHORIZATION_ID); + + const incident = requireObject(authorization.incident, "Break-glass incident"); + requireString(incident.id, "Break-glass incident id", INCIDENT_ID); + assert(incident.severity === "P0" || incident.severity === "P1", "Break-glass incident severity must be P0 or P1"); + requireString(incident.summarySha256, "Break-glass incident summarySha256", SHA256); + + const candidate = requireObject(authorization.candidate, "Break-glass candidate"); + assert(candidate.image === `ghcr.io/${expected.repository.toLowerCase()}`, "Break-glass candidate image does not match the repository"); + assert(candidate.digest === expected.candidateDigest, "Break-glass candidate digest does not match the dispatched digest"); + assert(candidate.sourceCommit === expected.sourceCommit, "Break-glass source commit does not match the dispatched commit"); + requireString(candidate.digest, "Break-glass candidate digest", SHA256); + requireString(candidate.sourceCommit, "Break-glass sourceCommit", COMMIT_SHA); + + const schemaDrift = requireObject(authorization.schemaDrift, "Break-glass schemaDrift"); + requireString(String(schemaDrift.workflowRunId), "Break-glass schema-drift workflowRunId", /^[1-9][0-9]*$/); + assert(ALLOWED_MISMATCHES.has(schemaDrift.expectedMismatch), "Break-glass schema-drift expectedMismatch is invalid"); + requireString(schemaDrift.baselineFingerprint, "Break-glass baselineFingerprint", FINGERPRINT); + requireString(schemaDrift.stagingFingerprint, "Break-glass stagingFingerprint", FINGERPRINT); + assert(schemaDrift.baselineFingerprint !== schemaDrift.stagingFingerprint, "Break-glass schema-drift fingerprints must differ"); + requireString(schemaDrift.queryPackSha256, "Break-glass queryPackSha256", SHA256); + requireString(schemaDrift.falsePositiveAnalysisSha256, "Break-glass falsePositiveAnalysisSha256", SHA256); + + const scope = requireObject(authorization.scope, "Break-glass scope"); + assert(scope.environment === policy.environment, "Break-glass authorization targets an unapproved environment"); + assert(scope.operation === "emergency-security-hotfix", "Break-glass authorization operation is invalid"); + assert(Array.isArray(scope.services) && scope.services.length > 0 && scope.services.length <= 32, + "Break-glass scope must contain 1 to 32 services"); + const services = new Set(); + for (const service of scope.services) { + requireString(service, "Break-glass service", SERVICE); + assert(!services.has(service), "Break-glass scope services must be unique"); + services.add(service); + } + const notBefore = parseCanonicalTimestamp(scope.notBefore, "Break-glass scope notBefore"); + const expiresAt = parseCanonicalTimestamp(scope.expiresAt, "Break-glass scope expiresAt"); + assert(expiresAt > notBefore, "Break-glass authorization expiry must be after notBefore"); + assert(expiresAt.getTime() - notBefore.getTime() <= policy.maximumAuthorizationMinutes * 60_000, + "Break-glass authorization exceeds the policy maximum duration"); + assert(now >= notBefore && now <= expiresAt, "Break-glass authorization is not currently active"); + + requireString(authorization.hotfixAuthor, "Break-glass hotfixAuthor", GITHUB_LOGIN); + return { authorization, authorizationSha256: sha256(stableStringify(authorization)), notBefore, expiresAt }; +} + +export function parseApprovalComment(comment) { + const body = typeof comment?.body === "string" ? comment.body : ""; + if (!body.includes(APPROVAL_MARKER)) return null; + assert(body.indexOf(APPROVAL_MARKER) === body.lastIndexOf(APPROVAL_MARKER), "Approval comment contains multiple authorization markers"); + const markerIndex = body.indexOf(APPROVAL_MARKER); + const block = body.slice(markerIndex + APPROVAL_MARKER.length).split("\n\n")[0]; + const fields = Object.create(null); + const allowedFields = new Set(["role", "authorization_id", "authorization_sha256", "expires_at"]); + for (const line of block.split("\n")) { + if (!line.trim()) continue; + const match = line.match(/^([a-z0-9_]+):\s*(.+)$/i); + assert(match, "Approval comment contains an invalid authorization field"); + const fieldName = match[1].toLowerCase(); + assert(allowedFields.has(fieldName), `Approval comment contains an unsupported authorization field: ${fieldName}`); + assert(fields[fieldName] === undefined, `Approval comment contains a duplicate authorization field: ${fieldName}`); + fields[fieldName] = match[2].trim(); + } + for (const fieldName of allowedFields) assert(typeof fields[fieldName] === "string", `Approval comment is missing required authorization field: ${fieldName}`); + return fields; +} + +async function fetchJson(fetchImpl, url, token) { + const response = await fetchImpl(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`); + return response.json(); +} + +async function fetchAllComments(fetchImpl, apiUrl, repository, issueNumber, token) { + const comments = []; + for (let page = 1; page <= 20; page += 1) { + const batch = await fetchJson(fetchImpl, + `${apiUrl}/repos/${encodeURIComponent(repository)}/issues/${issueNumber}/comments?per_page=100&page=${page}`, + token); + assert(Array.isArray(batch), "GitHub issue comments response is invalid"); + comments.push(...batch); + if (batch.length < 100) return comments; + } + throw new Error("GitHub issue has too many comments for break-glass verification; split or archive the incident record"); +} + +export function verifyIndependentApprovals(comments, verified, policy, dispatchActor) { + const selected = []; + const usedActors = new Set([verified.authorization.hotfixAuthor, dispatchActor]); + const byRole = new Map(); + const candidates = comments + .map(comment => ({ comment, fields: parseApprovalComment(comment) })) + .filter(entry => entry.fields !== null) + .sort((left, right) => String(left.comment.created_at).localeCompare(String(right.comment.created_at))); + + for (const role of REQUIRED_ROLES) { + const allowed = new Set(policy.roles[role].allowedLogins); + for (const { comment, fields } of candidates) { + const actor = String(comment?.user?.login ?? "").toLowerCase(); + if (fields.role !== role || fields.authorization_id !== verified.authorization.authorizationId || + fields.authorization_sha256 !== verified.authorizationSha256 || fields.expires_at !== verified.expiresAt.toISOString() || + !allowed.has(actor) || usedActors.has(actor)) continue; + const createdAt = parseCanonicalTimestamp(comment.created_at, `Approval comment for ${role} created_at`); + const updatedAt = parseCanonicalTimestamp(comment.updated_at ?? comment.created_at, `Approval comment for ${role} updated_at`); + assert(createdAt >= verified.notBefore && createdAt <= verified.expiresAt, + `Approval comment for ${role} is outside the authorization window`); + assert(updatedAt >= createdAt && updatedAt <= verified.expiresAt, + `Approval comment for ${role} was edited outside the authorization window`); + assert(typeof comment.html_url === "string" && /^https:\/\//.test(comment.html_url), `Approval comment for ${role} lacks a safe URL`); + byRole.set(role, { + role, + actor, + commentId: String(comment.id), + commentUrl: comment.html_url, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }); + usedActors.add(actor); + break; + } + assert(byRole.has(role), `Missing independent ${role} approval bound to authorization ${verified.authorization.authorizationId}`); + } + selected.push(...REQUIRED_ROLES.map(role => byRole.get(role))); + assert(new Set(selected.map(approval => approval.actor)).size === REQUIRED_ROLES.length, + "Break-glass approvals must be performed by three distinct identities"); + return selected; +} + +function parseArgs(argv) { + const values = Object.create(null); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + assert(token.startsWith("--"), `Unexpected argument: ${token}`); + const name = token.slice(2); + assert(["issue", "repository", "candidate-digest", "source-commit", "dispatch-actor", "policy", "out-dir", "now", "github-api-url"].includes(name), + `Unexpected argument: --${name}`); + assert(values[name] === undefined, `Duplicate argument: --${name}`); + const value = argv[index + 1]; + assert(value && !value.startsWith("--"), `Argument --${name} requires a value`); + values[name] = value; + index += 1; + } + for (const required of ["issue", "repository", "candidate-digest", "source-commit", "dispatch-actor", "policy", "out-dir"]) { + assert(values[required], `Missing required argument: --${required}`); + } + assert(/^[1-9][0-9]*$/.test(values.issue), "--issue must be a positive issue number"); + requireString(values.repository, "--repository", /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/); + requireString(values["candidate-digest"], "--candidate-digest", SHA256); + requireString(values["source-commit"], "--source-commit", COMMIT_SHA); + requireString(values["dispatch-actor"], "--dispatch-actor", GITHUB_LOGIN); + const apiUrl = values["github-api-url"] ?? process.env.GITHUB_API_URL ?? "https://api.github.com"; + assert(/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/api\/v3)?$/.test(apiUrl), "--github-api-url must be an HTTPS GitHub API origin"); + return { ...values, "github-api-url": apiUrl.replace(/\/$/, "") }; +} + +export async function verifyBreakGlassAuthorization(args, options = {}) { + const policy = await readAuthorizationPolicy(args.policy); + assert(policy.repository.toLowerCase() === args.repository.toLowerCase(), "Break-glass policy repository does not match dispatched repository"); + const now = args.now ? parseCanonicalTimestamp(args.now, "--now") : new Date(); + const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + assert(typeof token === "string" && token.length > 0, "A GitHub token is required to read the incident record and approvals"); + const fetchImpl = options.fetchImpl ?? fetch; + const issue = await fetchJson(fetchImpl, + `${args["github-api-url"]}/repos/${encodeURIComponent(args.repository)}/issues/${args.issue}`, + token); + assert(!issue.pull_request, "Break-glass authorization must be recorded in an incident issue, not a pull request"); + const issueLabels = new Set((issue.labels ?? []).map(label => typeof label === "string" ? label : label?.name).filter(Boolean)); + for (const requiredLabel of policy.requiredIssueLabels) { + assert(issueLabels.has(requiredLabel), `Incident issue is missing required label: ${requiredLabel}`); + } + const verified = validateAuthorizationRecord(extractAuthorizationFromIssueBody(issue.body), { + repository: args.repository, + candidateDigest: args["candidate-digest"], + sourceCommit: args["source-commit"], + }, policy, now); + const commit = await fetchJson(fetchImpl, + `${args["github-api-url"]}/repos/${encodeURIComponent(args.repository)}/commits/${args["source-commit"]}`, + token); + const commitAuthor = String(commit?.author?.login ?? "").toLowerCase(); + assert(GITHUB_LOGIN.test(commitAuthor), "The dispatched source commit must have a resolvable GitHub author identity"); + assert(verified.authorization.hotfixAuthor === commitAuthor, + "Break-glass hotfixAuthor does not match the dispatched source commit author"); + const comments = await fetchAllComments(fetchImpl, args["github-api-url"], args.repository, args.issue, token); + const approvals = verifyIndependentApprovals(comments, verified, policy, args["dispatch-actor"]); + const output = { + schemaVersion: "ndsep.break-glass-authorization-verification.v1", + verifiedAt: now.toISOString(), + repository: args.repository.toLowerCase(), + issue: { number: Number(args.issue), url: issue.html_url }, + policy: { id: policy.policyId, sha256: sha256(stableStringify(policy)), environment: policy.environment }, + authorization: { + id: verified.authorization.authorizationId, + sha256: verified.authorizationSha256, + incidentId: verified.authorization.incident.id, + severity: verified.authorization.incident.severity, + candidate: verified.authorization.candidate, + schemaDrift: verified.authorization.schemaDrift, + scope: verified.authorization.scope, + hotfixAuthor: verified.authorization.hotfixAuthor, + expiresAt: verified.expiresAt.toISOString(), + }, + independentApprovals: approvals, + dispatchActor: args["dispatch-actor"], + noReadinessCredit: true, + }; + await mkdir(args["out-dir"], { recursive: true, mode: 0o700 }); + const outputPath = resolve(args["out-dir"], "break-glass-authorization-verification.json"); + assert(dirname(outputPath) === resolve(args["out-dir"]), "Break-glass output path escaped its target directory"); + await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + return { output, outputPath }; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const args = parseArgs(process.argv.slice(2)); + const { output, outputPath } = await verifyBreakGlassAuthorization(args); + console.log(JSON.stringify({ status: "verified", authorizationId: output.authorization.id, output: outputPath }, null, 2)); + } catch (error) { + console.error(`Break-glass authorization verification failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} + +export { parseArgs }; diff --git a/scripts/ci/verify-break-glass-evidence.mjs b/scripts/ci/verify-break-glass-evidence.mjs new file mode 100644 index 00000000..540cb182 --- /dev/null +++ b/scripts/ci/verify-break-glass-evidence.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * Verify the tamper-evident break-glass evidence bundle before it is disclosed + * to an enterprise SIEM, alerting system, or external evidence reader. + * + * The verifier is intentionally offline: GitHub/Sigstore attestation validation + * is a separate authenticated step in the protected workflow. This command + * verifies the complete local evidence-root, manifest, copied-artifact hashes, + * and append-only audit ledger without contacting a network service. + */ +import { lstat, open } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { sha256, stableStringify } from "./verify-break-glass-authorization.mjs"; + +const SHA256 = /^sha256:[a-f0-9]{64}$/; +const COMMIT_SHA = /^[a-f0-9]{40}$/; +const EXPECTED_EVENTS = [ + "break_glass.authorization_verified", + "break_glass.candidate_revalidated", + "break_glass.supply_chain_verified", + "break_glass.exception_consumed", +]; +const REQUIRED_ARTIFACTS = [ + "authorizationVerification", + "releaseEvidence", + "cosignVerification", + "provenanceVerification", + "auditEvents", +]; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function requireObject(value, label) { + assert(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be a JSON object`); + return value; +} + +function requireString(value, label, pattern) { + assert(typeof value === "string" && value.length > 0, `${label} must be a non-empty string`); + if (pattern) assert(pattern.test(value), `${label} is invalid`); + return value; +} + +function parseCanonicalTimestamp(value, label) { + requireString(value, label); + const parsed = new Date(value); + assert(!Number.isNaN(parsed.getTime()) && parsed.toISOString() === value, `${label} must be a canonical UTC timestamp`); + return parsed; +} + +async function readRegularBuffer(path, label) { + const stat = await lstat(path); + assert(stat.isFile() && !stat.isSymbolicLink(), `${label} must be a regular non-symlink file`); + const handle = await open(path, "r"); + try { + const opened = await handle.stat(); + assert(opened.isFile(), `${label} must be a regular file`); + return await handle.readFile(); + } finally { + await handle.close(); + } +} + +async function readJson(path, label) { + const raw = await readRegularBuffer(path, label); + try { + return { raw, value: JSON.parse(raw.toString("utf8")) }; + } catch { + throw new Error(`${label} is not valid JSON`); + } +} + +function resolveBundlePath(evidenceDir, relativePath, label) { + requireString(relativePath, `${label} path`); + assert(relativePath === basename(relativePath), `${label} path must not contain directory components`); + const target = resolve(evidenceDir, relativePath); + assert(dirname(target) === resolve(evidenceDir), `${label} path escaped the evidence directory`); + return target; +} + +function verifyAuditEvent(row, expectedSequence, previousHash, common) { + requireObject(row, `Audit event ${expectedSequence}`); + assert(row.schemaVersion === "ndsep.break-glass-audit-event.v1", `Audit event ${expectedSequence} schema is unsupported`); + assert(row.sequence === expectedSequence, `Audit event sequence ${expectedSequence} is invalid`); + assert(row.eventType === EXPECTED_EVENTS[expectedSequence - 1], `Audit event ${expectedSequence} type is invalid`); + parseCanonicalTimestamp(row.occurredAt, `Audit event ${expectedSequence} occurredAt`); + assert(row.previousEventHash === previousHash, `Audit event ${expectedSequence} previousEventHash mismatch`); + requireString(row.eventHash, `Audit event ${expectedSequence} eventHash`, SHA256); + assert(row.candidate?.image === common.candidate.image, `Audit event ${expectedSequence} candidate image mismatch`); + assert(row.candidate?.digest === common.candidate.digest, `Audit event ${expectedSequence} candidate digest mismatch`); + assert(row.candidate?.sourceCommit === common.candidate.sourceCommit, `Audit event ${expectedSequence} source commit mismatch`); + assert(row.authorization?.id === common.authorization.id, `Audit event ${expectedSequence} authorization id mismatch`); + assert(row.authorization?.sha256 === common.authorization.sha256, `Audit event ${expectedSequence} authorization hash mismatch`); + const { eventHash, ...unsigned } = row; + assert(sha256(stableStringify(unsigned)) === eventHash, `Audit event ${expectedSequence} hash mismatch`); + return eventHash; +} + +function verifyManifestShape(manifest, root) { + requireObject(manifest, "Break-glass evidence manifest"); + requireObject(root, "Break-glass evidence root"); + assert(manifest.schemaVersion === "ndsep.break-glass-evidence-manifest.v1", "Break-glass evidence manifest schema is unsupported"); + assert(root.schemaVersion === "ndsep.break-glass-evidence-root.v1", "Break-glass evidence root schema is unsupported"); + parseCanonicalTimestamp(manifest.generatedAt, "Break-glass manifest generatedAt"); + assert(root.generatedAt === manifest.generatedAt, "Break-glass root generatedAt mismatch"); + requireString(manifest.repository, "Break-glass manifest repository", /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/); + assert(manifest.candidate?.image === `ghcr.io/${manifest.repository}`, "Break-glass manifest candidate image mismatch"); + requireString(manifest.candidate?.digest, "Break-glass candidate digest", SHA256); + requireString(manifest.candidate?.sourceCommit, "Break-glass source commit", COMMIT_SHA); + assert(root.candidate?.image === manifest.candidate.image && root.candidate?.digest === manifest.candidate.digest && root.candidate?.sourceCommit === manifest.candidate.sourceCommit, + "Break-glass root candidate binding mismatch"); + assert(root.authorization?.id === manifest.authorization?.id && root.authorization?.sha256 === manifest.authorization?.sha256, + "Break-glass root authorization binding mismatch"); + assert(manifest.integrity?.algorithm === "sha256", "Break-glass manifest integrity algorithm is unsupported"); + assert(manifest.integrity?.attestationRequired === true, "Break-glass manifest must require an attestation"); + assert(manifest.integrity?.externalImmutableRetentionRequired === true, "Break-glass manifest must require external immutable retention"); + assert(manifest.integrity?.noReadinessCredit === true && root.noReadinessCredit === true, + "Break-glass evidence must preserve no-readiness-credit semantics"); + return manifest; +} + +export async function verifyBreakGlassEvidence(evidenceDir) { + const directoryStat = await lstat(evidenceDir); + assert(directoryStat.isDirectory() && !directoryStat.isSymbolicLink(), "Evidence directory must be a real non-symlink directory"); + const normalizedDir = resolve(evidenceDir); + const manifestPath = resolveBundlePath(normalizedDir, "break-glass-evidence-manifest.json", "Manifest"); + const rootPath = resolveBundlePath(normalizedDir, "break-glass-evidence-root.json", "Root"); + const [{ raw: manifestRaw, value: manifest }, { raw: rootRaw, value: root }] = await Promise.all([ + readJson(manifestPath, "Break-glass evidence manifest"), + readJson(rootPath, "Break-glass evidence root"), + ]); + verifyManifestShape(manifest, root); + assert(root.manifest?.path === "break-glass-evidence-manifest.json", "Break-glass root manifest path is invalid"); + assert(root.manifest?.sha256 === sha256(manifestRaw), "Break-glass root manifest hash mismatch"); + requireString(root.audit?.path, "Break-glass root audit path"); + requireString(root.audit?.sha256, "Break-glass root audit hash", SHA256); + requireString(root.audit?.rootHash, "Break-glass root audit rootHash", SHA256); + + requireObject(manifest.artifacts, "Break-glass manifest artifacts"); + for (const artifactName of REQUIRED_ARTIFACTS) assert(manifest.artifacts[artifactName], `Break-glass manifest is missing required artifact: ${artifactName}`); + const artifactDigests = {}; + for (const [artifactName, descriptor] of Object.entries(manifest.artifacts)) { + requireObject(descriptor, `Artifact ${artifactName}`); + const path = resolveBundlePath(normalizedDir, descriptor.path, `Artifact ${artifactName}`); + const contents = await readRegularBuffer(path, `Artifact ${artifactName}`); + requireString(descriptor.sha256, `Artifact ${artifactName} sha256`, SHA256); + assert(sha256(contents) === descriptor.sha256, `Artifact ${artifactName} sha256 mismatch`); + artifactDigests[artifactName] = descriptor.sha256; + } + const authorizationArtifact = manifest.artifacts.authorizationVerification; + const releaseArtifact = manifest.artifacts.releaseEvidence; + const cosignArtifact = manifest.artifacts.cosignVerification; + const provenanceArtifact = manifest.artifacts.provenanceVerification; + const [{ value: authorizationVerification }, { value: releaseEvidence }, cosignContents, provenanceContents] = await Promise.all([ + readJson(resolveBundlePath(normalizedDir, authorizationArtifact.path, "Authorization verification artifact"), "Authorization verification artifact"), + readJson(resolveBundlePath(normalizedDir, releaseArtifact.path, "Release evidence artifact"), "Release evidence artifact"), + readRegularBuffer(resolveBundlePath(normalizedDir, cosignArtifact.path, "Cosign verification artifact"), "Cosign verification artifact"), + readRegularBuffer(resolveBundlePath(normalizedDir, provenanceArtifact.path, "Provenance verification artifact"), "Provenance verification artifact"), + ]); + assert(authorizationVerification.schemaVersion === "ndsep.break-glass-authorization-verification.v1", "Authorization verification artifact schema is unsupported"); + assert(authorizationVerification.authorization?.id === manifest.authorization.id && authorizationVerification.authorization?.sha256 === manifest.authorization.sha256, + "Authorization verification artifact does not match manifest authorization"); + assert(authorizationVerification.authorization?.candidate?.digest === manifest.candidate.digest && authorizationVerification.authorization?.candidate?.sourceCommit === manifest.candidate.sourceCommit, + "Authorization verification artifact candidate binding mismatch"); + assert(Array.isArray(authorizationVerification.independentApprovals) && new Set(authorizationVerification.independentApprovals.map(approval => approval.actor)).size === 3, + "Authorization verification artifact must contain three independent approval actors"); + assert(authorizationVerification.noReadinessCredit === true, "Authorization verification artifact must not grant readiness credit"); + assert(releaseEvidence.schema === "ndsep.release-evidence.v1", "Release evidence artifact schema is unsupported"); + assert(releaseEvidence.image?.name === manifest.candidate.image && releaseEvidence.image?.digest === manifest.candidate.digest && releaseEvidence.source_sha === manifest.candidate.sourceCommit, + "Release evidence artifact candidate binding mismatch"); + assert(releaseEvidence.trivy?.high_critical_count === 0, "Release evidence artifact contains HIGH/CRITICAL findings"); + assert(cosignContents.length > 0 && provenanceContents.length > 0, "Supply-chain verification artifacts must not be empty"); + + const audit = manifest.artifacts.auditEvents; + requireObject(audit, "Break-glass audit artifact"); + assert(audit.path === root.audit.path, "Break-glass audit path differs between manifest and root"); + assert(audit.sha256 === root.audit.sha256, "Break-glass audit hash differs between manifest and root"); + const auditPath = resolveBundlePath(normalizedDir, audit.path, "Break-glass audit artifact"); + const auditRaw = await readRegularBuffer(auditPath, "Break-glass audit artifact"); + assert(sha256(auditRaw) === audit.sha256, "Break-glass audit file hash mismatch"); + const lines = auditRaw.toString("utf8").split("\n").filter(Boolean); + assert(lines.length === EXPECTED_EVENTS.length && audit.count === EXPECTED_EVENTS.length, + "Break-glass audit ledger has an unexpected event count"); + const rows = lines.map((line, index) => { + try { return JSON.parse(line); } catch { throw new Error(`Break-glass audit event ${index + 1} is not valid JSON`); } + }); + const common = { candidate: manifest.candidate, authorization: manifest.authorization }; + let previousHash = `sha256:${"0".repeat(64)}`; + for (let index = 0; index < rows.length; index += 1) previousHash = verifyAuditEvent(rows[index], index + 1, previousHash, common); + assert(previousHash === audit.rootHash && previousHash === root.audit.rootHash, + "Break-glass audit root hash mismatch"); + assert(rows[0].data?.authorizationSha256 === manifest.authorization.sha256, + "Authorization audit event is not bound to the manifest authorization hash"); + assert(rows[1].data?.trivyHighCriticalCount === 0, "Candidate revalidation audit event is not clean"); + assert(rows[3].data?.noReadinessCredit === true, "Exception-consumed audit event must not grant readiness credit"); + + return { + schemaVersion: "ndsep.break-glass-evidence-verification.v1", + evidenceDirectory: normalizedDir, + repository: manifest.repository, + generatedAt: manifest.generatedAt, + workflow: manifest.workflow, + rootSha256: sha256(rootRaw), + manifestSha256: sha256(manifestRaw), + auditRootHash: previousHash, + candidate: manifest.candidate, + authorization: { + id: manifest.authorization.id, + sha256: manifest.authorization.sha256, + incidentId: manifest.authorization.incidentId, + expiresAt: manifest.authorization.expiresAt, + }, + artifactDigests, + verifiedEventTypes: rows.map(row => row.eventType), + noReadinessCredit: true, + }; +} + +function parseArgs(argv) { + assert(argv.length === 2 && argv[0] === "--evidence-dir", "usage: verify-break-glass-evidence.mjs --evidence-dir "); + assert(argv[1] && !argv[1].startsWith("--"), "--evidence-dir requires a value"); + return argv[1]; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + try { + const result = await verifyBreakGlassEvidence(parseArgs(process.argv.slice(2))); + console.log(JSON.stringify({ status: "verified", ...result }, null, 2)); + } catch (error) { + console.error(`Break-glass evidence verification failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/ci/verify-e2e-matrix-artifacts.mjs b/scripts/ci/verify-e2e-matrix-artifacts.mjs new file mode 100644 index 00000000..658d5558 --- /dev/null +++ b/scripts/ci/verify-e2e-matrix-artifacts.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Validates downloaded artifacts from the NDSEP extended Chromium E2E matrix. + * + * This verifier is an evidence validator. It does not execute browser tests, + * create GitHub artifacts, or authorize a release. Every required shard must + * provide an independently produced manifest and Playwright JSON result tied + * to the exact candidate commit. + */ + +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const EXPECTED_SHARDS = Object.freeze([ + { id: "auth", project: "chromium", expectedTests: 17 }, + { id: "critical-flows", project: "chromium", expectedTests: 34 }, + { id: "dpco-onboarding", project: "chromium", expectedTests: 20 }, + { id: "enforcement-loop", project: "chromium", expectedTests: 24 }, + { id: "penalty-enforcement", project: "chromium", expectedTests: 18 }, + { id: "temporal-kafka", project: "chromium", expectedTests: 16 }, + { id: "critical-workflows", project: "chromium", expectedTests: 15 }, + { id: "visual-regression", project: "chromium", expectedTests: 12 }, +]); + +function requiredEnvironment(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function parseJson(filePath, label) { + return readFile(filePath, "utf8") + .then(content => JSON.parse(content)) + .catch(error => { + throw new Error(`${label} at ${filePath} is invalid: ${error instanceof Error ? error.message : String(error)}`); + }); +} + +function integerField(value, label) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function validateResultStats(result, shard) { + if (!result || typeof result !== "object" || Array.isArray(result)) { + throw new Error(`${shard.id}: Playwright result must be an object`); + } + const stats = result.stats; + if (!stats || typeof stats !== "object" || Array.isArray(stats)) { + throw new Error(`${shard.id}: Playwright result has no stats object`); + } + + const expected = integerField(stats.expected, `${shard.id}: stats.expected`); + const unexpected = integerField(stats.unexpected, `${shard.id}: stats.unexpected`); + const flaky = integerField(stats.flaky, `${shard.id}: stats.flaky`); + const skipped = integerField(stats.skipped, `${shard.id}: stats.skipped`); + + if (expected !== shard.expectedTests) { + throw new Error(`${shard.id}: expected ${shard.expectedTests} executable tests but result reports ${expected}`); + } + if (unexpected !== 0) throw new Error(`${shard.id}: result contains ${unexpected} unexpected test failure(s)`); + if (flaky !== 0) throw new Error(`${shard.id}: result contains ${flaky} flaky/retried test(s)`); + if (skipped !== 0) throw new Error(`${shard.id}: result contains ${skipped} skipped test(s)`); + + return { expected, unexpected, flaky, skipped, duration: stats.duration ?? null }; +} + +function validateManifest(manifest, shard, commit) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new Error(`${shard.id}: shard manifest must be an object`); + } + const expectedShardName = `${shard.id}-${shard.project}`; + if (manifest.candidateCommit !== commit) { + throw new Error(`${shard.id}: manifest candidateCommit does not match ${commit}`); + } + if (manifest.shard !== expectedShardName) { + throw new Error(`${shard.id}: manifest shard must equal ${expectedShardName}`); + } + if (manifest.project !== shard.project) { + throw new Error(`${shard.id}: manifest project must equal ${shard.project}`); + } + if (typeof manifest.testRunId !== "string" || manifest.testRunId.trim().length < 1) { + throw new Error(`${shard.id}: manifest testRunId is required`); + } +} + +async function main() { + const artifactRoot = path.resolve(requiredEnvironment("E2E_ARTIFACT_ROOT")); + const commit = requiredEnvironment("E2E_CANDIDATE_COMMIT"); + if (!/^[a-f0-9]{40}$/i.test(commit)) { + throw new Error("E2E_CANDIDATE_COMMIT must be a full 40-character Git commit SHA"); + } + if (!existsSync(artifactRoot)) throw new Error(`E2E_ARTIFACT_ROOT does not exist: ${artifactRoot}`); + + const summaries = []; + for (const shard of EXPECTED_SHARDS) { + const artifactName = `e2e-${commit}-${shard.id}-${shard.project}`; + const artifactPath = path.join(artifactRoot, artifactName); + const manifestPath = path.join(artifactPath, "e2e-shard-manifest.json"); + const resultsPath = path.join(artifactPath, "test-results.json"); + if (!existsSync(artifactPath)) throw new Error(`missing required shard artifact directory: ${artifactName}`); + if (!existsSync(manifestPath)) throw new Error(`${shard.id}: missing e2e-shard-manifest.json`); + if (!existsSync(resultsPath)) throw new Error(`${shard.id}: missing test-results.json`); + + const [manifest, result] = await Promise.all([ + parseJson(manifestPath, `${shard.id} shard manifest`), + parseJson(resultsPath, `${shard.id} Playwright result`), + ]); + validateManifest(manifest, shard, commit); + summaries.push({ + artifactName, + shard: `${shard.id}-${shard.project}`, + ...validateResultStats(result, shard), + }); + } + + const totalExpected = summaries.reduce((sum, summary) => sum + summary.expected, 0); + const output = { + schemaVersion: 1, + candidateCommit: commit, + generatedAt: new Date().toISOString(), + status: "complete_without_retries", + totalExpectedTests: totalExpected, + shardCount: summaries.length, + shards: summaries, + }; + const outputPath = path.resolve(process.env.E2E_MATRIX_SUMMARY_PATH || path.join(artifactRoot, "e2e-matrix-evidence.json")); + await mkdir(path.dirname(outputPath), { recursive: true }); + const temporaryPath = `${outputPath}.tmp-${process.pid}`; + await writeFile(temporaryPath, `${JSON.stringify(output, null, 2)}\n`, "utf8"); + await rename(temporaryPath, outputPath); + process.stdout.write(`${JSON.stringify(output)}\n`); +} + +main().catch(error => { + process.stderr.write(`E2E matrix evidence verification failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/ci/verify-post-merge-governance.mjs b/scripts/ci/verify-post-merge-governance.mjs new file mode 100644 index 00000000..4f438951 --- /dev/null +++ b/scripts/ci/verify-post-merge-governance.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const API_VERSION = "2026-03-10"; + +function requireCondition(errors, condition, message) { + if (!condition) errors.push(message); +} + +function parseRepository(value) { + const [owner, repo, ...extra] = String(value).split("/"); + if (!owner || !repo || extra.length > 0) throw new Error(`Invalid repository '${value}'; expected OWNER/REPO`); + return { owner, repo }; +} + +function loadFixture(fixtureDirectory, filename) { + return JSON.parse(readFileSync(resolve(fixtureDirectory, filename), "utf8")); +} + +function ghJson(endpoint) { + try { + const output = execFileSync( + "gh", + ["api", "-H", "Accept: application/vnd.github+json", "-H", `X-GitHub-Api-Version: ${API_VERSION}`, endpoint], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } + ); + return JSON.parse(output.replace(/\u001B\[[0-9;]*[A-Za-z]/g, "")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { requestError: message }; + } +} + +function loadEvidence({ owner, repo, branch, pullRequest, fixtureDirectory }) { + if (fixtureDirectory) { + return { + codeowners: loadFixture(fixtureDirectory, "codeowners.json"), + codeownersErrors: loadFixture(fixtureDirectory, "codeowners-errors.json"), + protection: loadFixture(fixtureDirectory, "production-protection.json"), + environment: loadFixture(fixtureDirectory, "production-release-environment.json"), + pullRequest: loadFixture(fixtureDirectory, "pr19.json"), + }; + } + + const base = `repos/${owner}/${repo}`; + return { + codeowners: ghJson(`${base}/contents/.github/CODEOWNERS?ref=${encodeURIComponent(branch)}`), + codeownersErrors: ghJson(`${base}/codeowners/errors`), + protection: ghJson(`${base}/branches/${encodeURIComponent(branch)}/protection`), + environment: ghJson(`${base}/environments/production-release`), + pullRequest: ghJson(`${base}/pulls/${pullRequest}`), + }; +} + +function evaluatePostMergeGovernance({ owner, repo, branch, pullRequest, evidence }) { + const errors = []; + const protection = evidence.protection ?? {}; + const reviews = protection.required_pull_request_reviews ?? {}; + const requiredChecks = protection.required_status_checks ?? {}; + const environment = evidence.environment ?? {}; + const environmentRules = environment.protection_rules ?? []; + const requiredReviewerRule = environmentRules.find((rule) => rule?.type === "required_reviewers"); + const waitTimerRule = environmentRules.find((rule) => rule?.type === "wait_timer"); + const branchPolicy = environment.deployment_branch_policy ?? {}; + const codeownerErrors = evidence.codeownersErrors?.errors ?? []; + + for (const [label, record] of Object.entries(evidence)) { + requireCondition(errors, !record?.requestError, `${label}: GitHub evidence request failed (${record?.requestError ?? "unknown error"})`); + } + requireCondition(errors, evidence.pullRequest?.number === pullRequest, `PR #${pullRequest}: evidence is missing or identifies a different pull request`); + requireCondition(errors, evidence.pullRequest?.merged_at, `PR #${pullRequest}: bootstrap pull request is not merged`); + requireCondition(errors, evidence.pullRequest?.base?.ref === branch, `PR #${pullRequest}: bootstrap did not merge into ${branch}`); + requireCondition(errors, evidence.codeowners?.path === ".github/CODEOWNERS", `${branch}: .github/CODEOWNERS is missing from the base branch`); + requireCondition(errors, typeof evidence.codeowners?.content === "string" && evidence.codeowners.content.length > 0, `${branch}: CODEOWNERS content is empty or unavailable`); + requireCondition(errors, !evidence.codeownersErrors?.requestError && codeownerErrors.length === 0, `${branch}: GitHub reports ${codeownerErrors.length} CODEOWNERS error(s)`); + requireCondition(errors, reviews.required_approving_review_count >= 2, `${branch}: requires fewer than two approvals`); + requireCondition(errors, reviews.require_code_owner_reviews === true, `${branch}: required CODEOWNER review is disabled`); + requireCondition(errors, reviews.dismiss_stale_reviews === true, `${branch}: stale reviews are not dismissed`); + requireCondition(errors, reviews.require_last_push_approval === true, `${branch}: last-push approval is disabled`); + requireCondition(errors, requiredChecks.strict === true, `${branch}: strict required status checks are disabled`); + requireCondition(errors, protection.enforce_admins?.enabled === true, `${branch}: administrator enforcement is disabled`); + requireCondition(errors, protection.allow_force_pushes?.enabled === false, `${branch}: force pushes are allowed`); + requireCondition(errors, protection.allow_deletions?.enabled === false, `${branch}: branch deletion is allowed`); + requireCondition(errors, environment.name === "production-release", "production-release: environment is missing or misnamed"); + requireCondition(errors, requiredReviewerRule?.reviewers?.length > 0, "production-release: required reviewer protection is absent"); + requireCondition(errors, environment.prevent_self_review === true, "production-release: self-review prevention is disabled"); + requireCondition(errors, environment.can_admins_bypass === false, "production-release: administrator bypass is enabled"); + requireCondition(errors, branchPolicy.protected_branches === true && branchPolicy.custom_branch_policies === false, "production-release: protected-branch deployment policy is not enforced"); + requireCondition(errors, waitTimerRule === undefined || Number(waitTimerRule.wait_timer ?? 0) >= 0, "production-release: invalid wait-timer rule"); + + return { + status: errors.length === 0 ? "passed" : "blocked", + repository: `${owner}/${repo}`, + branch, + pullRequest, + errors, + }; +} + +function parseArguments(argumentsList) { + const options = { repository: "munisp/ndsep", branch: "production", pullRequest: 19, fixtureDirectory: null }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + const value = argumentsList[index + 1]; + if (argument === "--repo" && value) { + options.repository = value; + index += 1; + } else if (argument === "--branch" && value) { + options.branch = value; + index += 1; + } else if (argument === "--pr" && value) { + options.pullRequest = Number(value); + index += 1; + } else if (argument === "--fixture-dir" && value) { + options.fixtureDirectory = value; + index += 1; + } else { + throw new Error("Usage: node scripts/ci/verify-post-merge-governance.mjs [--repo OWNER/REPO] [--branch production] [--pr 19] [--fixture-dir path]"); + } + } + if (!Number.isInteger(options.pullRequest) || options.pullRequest <= 0) { + throw new Error("--pr must be a positive integer"); + } + return options; +} + +export function verifyPostMergeGovernance(options) { + const { owner, repo } = parseRepository(options.repository); + const evidence = loadEvidence({ owner, repo, branch: options.branch, pullRequest: options.pullRequest, fixtureDirectory: options.fixtureDirectory }); + return evaluatePostMergeGovernance({ owner, repo, branch: options.branch, pullRequest: options.pullRequest, evidence }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = verifyPostMergeGovernance(parseArguments(process.argv.slice(2))); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.status === "passed" ? 0 : 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } +} diff --git a/scripts/ci/verify-production-go-evidence.mjs b/scripts/ci/verify-production-go-evidence.mjs new file mode 100644 index 00000000..bc76cddd --- /dev/null +++ b/scripts/ci/verify-production-go-evidence.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { verifyReadiness60Evidence } from "./verify-60-readiness-evidence.mjs"; + +const FULL_SHA = /^[a-f0-9]{40}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const REQUIRED_GO_SERVICES = [ + "postgresql", "tigerbeetle", "redis", "mojaloop", "kafka", "apisix", + "keycloak", "openappsec", "permify", "opensearch", "fluvio", "dapr", + "temporal", "lakehouse", +]; +const REQUIRED_GO_ROLES = [ + "release-security", "security", "sre", "data-owner", "compliance-evidence-verifier", + "engineering-owner", "compliance-officer", "business-owner", "release-manager", +]; + +function requireCondition(errors, condition, message) { + if (!condition) errors.push(message); +} + +function readJson(evidenceDir, name, errors) { + try { + return JSON.parse(readFileSync(resolve(evidenceDir, name), "utf8")); + } catch (error) { + errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +function validTimestamp(value) { + return typeof value === "string" && ISO_UTC.test(value) && !Number.isNaN(Date.parse(value)); +} + +function validEvidenceUri(value) { + return typeof value === "string" && /^https:\/\//.test(value); +} + +function verifyMergedGovernance(candidate, governance, errors) { + const merged = governance?.mergedPullRequest; + requireCondition(errors, Number.isInteger(merged?.number) && merged.number > 0, "governance.json: mergedPullRequest.number is required"); + requireCondition(errors, merged?.merged === true, "governance.json: mergedPullRequest must be normally merged"); + requireCondition(errors, merged?.mergeCommit === candidate?.sourceCommit, "governance.json: mergedPullRequest.mergeCommit must equal candidate sourceCommit"); + requireCondition(errors, typeof merged?.author === "string" && merged.author.length > 0, "governance.json: mergedPullRequest.author is required"); + requireCondition(errors, validTimestamp(merged?.mergedAt), "governance.json: mergedPullRequest.mergedAt must be canonical UTC evidence"); + requireCondition(errors, validEvidenceUri(merged?.evidenceUri), "governance.json: mergedPullRequest.evidenceUri must be an HTTPS immutable record"); + + const approved = Array.isArray(merged?.approvals) ? merged.approvals : []; + const independentActors = new Set(); + for (const review of approved) { + if (review?.state !== "APPROVED") continue; + if (typeof review.actor !== "string" || review.actor.length === 0 || review.actor === merged.author) continue; + if (!validTimestamp(review.submittedAt) || !validEvidenceUri(review.evidenceUri)) continue; + independentActors.add(review.actor); + } + requireCondition(errors, independentActors.size >= 2, "governance.json: two independent, timestamped approval records are required"); + requireCondition(errors, governance?.productionReleaseEnvironment?.requiredReviewers === true, "governance.json: production-release required reviewers are not verified"); + requireCondition(errors, governance?.productionReleaseEnvironment?.allowAdminBypass === false, "governance.json: production-release administrator bypass must be disabled"); +} + +function verifyCandidateAndArtifacts(candidate, artifacts, errors) { + requireCondition(errors, candidate?.releaseWorkflow?.path === ".github/workflows/ci.yml", "candidate.json: production release workflow path is required"); + requireCondition(errors, Number.isInteger(candidate?.releaseWorkflow?.runId) && candidate.releaseWorkflow.runId > 0, "candidate.json: production release workflow runId is required"); + requireCondition(errors, validEvidenceUri(candidate?.releaseWorkflow?.evidenceUri), "candidate.json: production release workflow evidenceUri must be HTTPS"); + for (const name of ["sbom", "provenance", "cosign"]) { + const record = artifacts?.[name]; + requireCondition(errors, SHA256.test(record?.sha256 ?? ""), `artifact-evidence.json: ${name} requires a SHA-256 evidence hash`); + requireCondition(errors, validEvidenceUri(record?.uri), `artifact-evidence.json: ${name} URI must be HTTPS`); + } + requireCondition(errors, artifacts?.cosign?.verification?.issuer === "https://token.actions.githubusercontent.com", "artifact-evidence.json: Cosign OIDC issuer verification is required"); + requireCondition(errors, artifacts?.provenance?.verification?.subjectDigestBound === true, "artifact-evidence.json: provenance must verify the candidate digest subject"); +} + +function verifyStagingAndServices(candidate, staging, services, errors) { + requireCondition(errors, Number.isInteger(staging?.workflowRunId) && staging.workflowRunId > 0, "staging-deployment.json: protected staging workflowRunId is required"); + requireCondition(errors, validEvidenceUri(staging?.evidenceUri), "staging-deployment.json: protected staging evidenceUri must be HTTPS"); + requireCondition(errors, staging?.deploymentCommit === candidate?.sourceCommit, "staging-deployment.json: deploymentCommit must equal candidate sourceCommit"); + for (const check of ["rollback", "database-recovery", "network-policy", "pwa-smoke", "mobile-smoke"]) { + requireCondition(errors, staging?.checks?.some(item => item?.name === check && item?.status === "passed"), `staging-deployment.json: mandatory '${check}' check is absent or failed`); + } + + const rows = new Map((services?.services ?? []).map(service => [service?.name, service])); + for (const name of REQUIRED_GO_SERVICES) { + const service = rows.get(name); + requireCondition(errors, service?.status === "passed", `service-matrix.json: '${name}' GO acceptance is absent or failed`); + requireCondition(errors, service?.digest === candidate?.image?.split("@sha256:")[1], `service-matrix.json: '${name}' is not bound to candidate digest`); + requireCondition(errors, Number.isInteger(service?.workflowRunId) && service.workflowRunId > 0, `service-matrix.json: '${name}' has no protected workflowRunId`); + requireCondition(errors, validEvidenceUri(service?.evidenceUri), `service-matrix.json: '${name}' evidenceUri must be HTTPS`); + } +} + +function verifyOperationalProof(candidate, postgres, resilience, residency, errors) { + for (const [name, record] of [ + ["postgres-integrity.json", postgres], + ["resilience.json", resilience], + ["residency-evidence.json", residency], + ]) { + requireCondition(errors, record?.candidateDigest === candidate?.image?.split("@sha256:")[1], `${name}: candidateDigest must bind to candidate`); + requireCondition(errors, Number.isInteger(record?.workflowRunId) && record.workflowRunId > 0, `${name}: protected workflowRunId is required`); + requireCondition(errors, validEvidenceUri(record?.evidenceUri), `${name}: evidenceUri must be HTTPS`); + } +} + +function verifyAccountableAcceptance(candidate, approvals, errors) { + requireCondition(errors, approvals?.candidateDigest === candidate?.image?.split("@sha256:")[1], "approvals.json: candidateDigest must bind to candidate"); + for (const role of REQUIRED_GO_ROLES) { + const acceptance = approvals?.acceptances?.find(item => item?.role === role && item?.decision === "accepted"); + requireCondition(errors, acceptance !== undefined, `approvals.json: missing accepted '${role}' approval`); + requireCondition(errors, typeof acceptance?.actor === "string" && acceptance.actor.length > 0, `approvals.json: '${role}' approval needs accountable actor`); + requireCondition(errors, validTimestamp(acceptance?.acceptedAt), `approvals.json: '${role}' approval needs canonical UTC acceptedAt`); + requireCondition(errors, validEvidenceUri(acceptance?.evidenceUri), `approvals.json: '${role}' approval needs HTTPS evidenceUri`); + } +} + +export function verifyProductionGoEvidence(evidenceDirectory) { + const foundation = verifyReadiness60Evidence(evidenceDirectory); + const errors = [...foundation.errors]; + const candidate = readJson(evidenceDirectory, "candidate.json", errors); + const artifacts = readJson(evidenceDirectory, "artifact-evidence.json", errors); + const governance = readJson(evidenceDirectory, "governance.json", errors); + const staging = readJson(evidenceDirectory, "staging-deployment.json", errors); + const services = readJson(evidenceDirectory, "service-matrix.json", errors); + const postgres = readJson(evidenceDirectory, "postgres-integrity.json", errors); + const resilience = readJson(evidenceDirectory, "resilience.json", errors); + const residency = readJson(evidenceDirectory, "residency-evidence.json", errors); + const approvals = readJson(evidenceDirectory, "approvals.json", errors); + + requireCondition(errors, FULL_SHA.test(candidate?.sourceCommit ?? ""), "candidate.json: sourceCommit must be a full lower-case SHA"); + verifyMergedGovernance(candidate, governance, errors); + verifyCandidateAndArtifacts(candidate, artifacts, errors); + verifyStagingAndServices(candidate, staging, services, errors); + verifyOperationalProof(candidate, postgres, resilience, residency, errors); + verifyAccountableAcceptance(candidate, approvals, errors); + + return { + status: errors.length === 0 ? "passed" : "blocked", + threshold: "95/100", + evidenceDirectory: resolve(evidenceDirectory), + errors, + }; +} + +function parseArguments(argumentsList) { + const index = argumentsList.indexOf("--evidence-dir"); + if (index === -1 || !argumentsList[index + 1]) { + throw new Error("Usage: node scripts/ci/verify-production-go-evidence.mjs --evidence-dir /secure/path/to/evidence"); + } + return argumentsList[index + 1]; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = verifyProductionGoEvidence(parseArguments(process.argv.slice(2))); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.status === "passed" ? 0 : 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } +} diff --git a/scripts/ci/verify-staging-schema-drift.mjs b/scripts/ci/verify-staging-schema-drift.mjs new file mode 100644 index 00000000..cb2ef159 --- /dev/null +++ b/scripts/ci/verify-staging-schema-drift.mjs @@ -0,0 +1,308 @@ +#!/usr/bin/env node +/** + * Read-only staging schema-drift verifier for protected deployment workflows. + * + * This command never writes fixture data, applies migrations, or runs on a pull + * request. It compares a clean same-commit baseline database with a protected + * staging fixture database and exits nonzero on any schema mismatch. + * + * Required environment variables: + * SCHEMA_DRIFT_BASELINE_DATABASE_URL + * SCHEMA_DRIFT_STAGING_DATABASE_URL + * + * Both endpoints must be distinct, non-production PostgreSQL targets using + * sslmode=verify-full and a read-only database role. Do not print either URL. + */ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { URL } from "node:url"; +import pg from "pg"; + +export const SCHEMA_DRIFT_APPLICATION_NAME = "ndsep-ci-schema-drift-verifier"; +const PRODUCTION_DATABASE_NAMES = /(?:^|[-_.])(prod(?:uction)?|live)(?:$|[-_.])/i; +const STAGING_DATABASE_NAMES = /(?:staging|synthetic|demo|test|sandbox)/i; +const REQUIRED_QUERY_PACK_MARKERS = Object.freeze([ + "BEGIN TRANSACTION READ ONLY;", + "schema_column_fingerprint", + "schema_constraint_fingerprint", + "schema_index_fingerprint", + "fixture_counts AS", + "ROLLBACK;", +]); +const MUTATING_SQL_TOKENS = /\b(?:INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|COPY|CALL|DO|VACUUM|ANALYZE)\b/i; + +export const REQUIRED_TABLES = Object.freeze([ + "analytics_snapshots", + "article40_codes", + "dpco_audit_service_records", + "dpco_registry_service_records", + "dpco_verification_service_records", + "dt_jurisdictions", + "dt_policies", + "dt_org_agents", + "dt_simulations", + "dt_simulation_results", + "dt_monte_carlo_stats", + "dt_policy_impacts", + "dt_sandboxes", + "marketplace_plugins", + "mobile_push_devices", + "noc_agent_memory", + "onboarding_checklists", + "users", +]); + +export const REQUIRED_COLUMNS = Object.freeze([ + ["analytics_snapshots", "metric_name"], ["analytics_snapshots", "dimension"], + ["analytics_snapshots", "dimension_value"], ["analytics_snapshots", "metric_value"], + ["analytics_snapshots", "snapshot_date"], ["analytics_snapshots", "metadata"], + ["article40_codes", "code_name"], ["article40_codes", "sector"], + ["article40_codes", "description"], ["article40_codes", "submitted_by"], + ["article40_codes", "document_url"], ["article40_codes", "status"], + ["dpco_audit_service_records", "audit_id"], ["dpco_audit_service_records", "payload"], + ["dpco_registry_service_records", "registry_id"], ["dpco_registry_service_records", "payload"], + ["dpco_verification_service_records", "statement_id"], ["dpco_verification_service_records", "payload"], + ["dt_jurisdictions", "code"], ["dt_jurisdictions", "name"], + ["dt_policies", "jurisdiction_id"], ["dt_policies", "code"], ["dt_policies", "rules"], + ["dt_org_agents", "jurisdiction_id"], ["dt_org_agents", "org_name"], + ["dt_simulations", "simulation_id"], ["dt_simulations", "jurisdictions"], + ["dt_simulation_results", "simulation_id"], ["dt_simulation_results", "jurisdiction"], + ["dt_monte_carlo_stats", "simulation_id"], ["dt_monte_carlo_stats", "metric"], + ["dt_policy_impacts", "simulation_id"], ["dt_policy_impacts", "policy_id"], + ["dt_sandboxes", "sandbox_id"], ["dt_sandboxes", "base_snapshot"], + ["marketplace_plugins", "name"], ["marketplace_plugins", "manifest"], + ["mobile_push_devices", "user_id"], ["mobile_push_devices", "token"], ["mobile_push_devices", "device_id"], + ["noc_agent_memory", "memory_id"], ["noc_agent_memory", "context"], + ["onboarding_checklists", "user_id"], ["onboarding_checklists", "step_id"], + ["users", "open_id"], ["users", "role"], +]); + +function databaseName(url) { + return decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] ?? ""); +} + +function redactedTarget(parsed) { + return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}/${databaseName(parsed)}`; +} + +function stripSqlCommentsAndStrings(sql) { + return sql + .replace(/--[^\n]*/g, "") + .replace(/'(?:''|[^'])*'/g, "''"); +} + +export function validateSchemaDriftTarget(value, label) { + if (!value) throw new Error(`${label} is required`); + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${label} must be a valid PostgreSQL URL`); + } + if (!/^postgres(?:ql)?:$/.test(parsed.protocol)) { + throw new Error(`${label} must use the PostgreSQL protocol`); + } + const name = databaseName(parsed); + if (!name) throw new Error(`${label} must include a database name`); + if (PRODUCTION_DATABASE_NAMES.test(name)) { + throw new Error(`${label} refuses a production- or live-labelled database`); + } + if (!STAGING_DATABASE_NAMES.test(name)) { + throw new Error(`${label} must identify a staging, synthetic, demo, test, or sandbox database`); + } + if (parsed.searchParams.get("sslmode") !== "verify-full") { + throw new Error(`${label} requires sslmode=verify-full`); + } + return parsed; +} + +export function requireDistinctTargets(baseline, staging) { + if (redactedTarget(baseline) === redactedTarget(staging)) { + throw new Error("Schema-drift baseline and staging targets must be distinct databases"); + } +} + +/** + * Uses the runner trust store by default. A protected environment may supply a + * private CA bundle, but an arbitrary secret value must never disable or weaken + * certificate verification. + */ +export function getVerifiedSchemaDriftSslConfig(environment = process.env) { + const ca = environment.SCHEMA_DRIFT_DB_SSL_CA_PEM; + if (ca === undefined || ca === "") return { rejectUnauthorized: true }; + if (!ca.includes("-----BEGIN CERTIFICATE-----") || !ca.includes("-----END CERTIFICATE-----")) { + throw new Error("SCHEMA_DRIFT_DB_SSL_CA_PEM must contain a PEM certificate bundle"); + } + return { rejectUnauthorized: true, ca }; +} + +export function assertReadOnlyQueryPack(sql) { + if (typeof sql !== "string" || sql.trim().length === 0) { + throw new Error("Schema-drift verification SQL must be non-empty"); + } + for (const marker of REQUIRED_QUERY_PACK_MARKERS) { + if (!sql.includes(marker)) throw new Error(`Schema-drift verification SQL is missing required marker: ${marker}`); + } + const executableSql = stripSqlCommentsAndStrings(sql); + const unsafe = executableSql.match(MUTATING_SQL_TOKENS); + if (unsafe) throw new Error(`Schema-drift verification SQL must be read-only; prohibited token: ${unsafe[0].toUpperCase()}`); + return true; +} + +export function assertSchemaComparison(baseline, staging) { + for (const key of ["column", "constraint", "index"]) { + if (typeof baseline?.fingerprints?.[key] !== "string" || !/^[a-f0-9]{32}$/.test(baseline.fingerprints[key])) { + throw new Error(`Baseline ${key} fingerprint is invalid`); + } + if (typeof staging?.fingerprints?.[key] !== "string" || !/^[a-f0-9]{32}$/.test(staging.fingerprints[key])) { + throw new Error(`Staging ${key} fingerprint is invalid`); + } + if (baseline.fingerprints[key] !== staging.fingerprints[key]) { + throw new Error(`Schema drift detected: ${key} fingerprint differs`); + } + } + for (const [label, report] of [["baseline", baseline], ["staging", staging]]) { + if (report.missingTables.length > 0) throw new Error(`${label} schema is missing required table(s): ${report.missingTables.join(", ")}`); + if (report.missingColumns.length > 0) throw new Error(`${label} schema is missing required column(s): ${report.missingColumns.join(", ")}`); + if (report.unvalidatedConstraints.length > 0) throw new Error(`${label} schema has unvalidated constraint(s)`); + if (report.invalidIndexes.length > 0) throw new Error(`${label} schema has invalid or unready index(es)`); + if (report.transactionReadOnly !== "on") throw new Error(`${label} schema verification session is not read-only`); + } + return true; +} + +const COLUMN_FINGERPRINT_QUERY = ` +WITH columns_contract AS ( + SELECT table_schema, table_name, ordinal_position, column_name, data_type, udt_schema, + udt_name, is_nullable, COALESCE(column_default, '') AS column_default + FROM information_schema.columns WHERE table_schema = 'public' +), canonical AS ( + SELECT string_agg(format('%s.%s#%s:%s:%s.%s:%s:%s:%s', table_schema, table_name, + ordinal_position, column_name, data_type, udt_schema, udt_name, is_nullable, column_default), + E'\\n' ORDER BY table_schema, table_name, ordinal_position) AS contents FROM columns_contract +) SELECT md5(COALESCE(contents, '')) AS fingerprint FROM canonical`; + +const CONSTRAINT_FINGERPRINT_QUERY = ` +WITH constraint_contract AS ( + SELECT conrelid::regclass::text AS table_name, conname, contype, condeferrable, + condeferred, convalidated, pg_get_constraintdef(oid, true) AS definition + FROM pg_constraint WHERE connamespace = 'public'::regnamespace +), canonical AS ( + SELECT string_agg(format('%s:%s:%s:%s:%s:%s:%s', table_name, conname, contype, + condeferrable, condeferred, convalidated, definition), E'\\n' ORDER BY table_name, conname) AS contents + FROM constraint_contract +) SELECT md5(COALESCE(contents, '')) AS fingerprint FROM canonical`; + +const INDEX_FINGERPRINT_QUERY = ` +WITH index_contract AS ( + SELECT schemaname, tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' +), canonical AS ( + SELECT string_agg(format('%s.%s:%s:%s', schemaname, tablename, indexname, indexdef), + E'\\n' ORDER BY schemaname, tablename, indexname) AS contents FROM index_contract +) SELECT md5(COALESCE(contents, '')) AS fingerprint FROM canonical`; + +async function collectSchemaReport(client) { + const tableNames = REQUIRED_TABLES; + const columnTables = REQUIRED_COLUMNS.map(([table]) => table); + const columnNames = REQUIRED_COLUMNS.map(([, column]) => column); + const [session, missingTables, missingColumns, unvalidatedConstraints, invalidIndexes, column, constraint, index] = await Promise.all([ + client.query("SHOW transaction_read_only"), + client.query(`SELECT expected.table_name + FROM unnest($1::text[]) AS expected(table_name) + LEFT JOIN information_schema.tables actual + ON actual.table_schema = 'public' AND actual.table_name = expected.table_name + WHERE actual.table_name IS NULL ORDER BY expected.table_name`, [tableNames]), + client.query(`SELECT expected.table_name, expected.column_name + FROM unnest($1::text[], $2::text[]) AS expected(table_name, column_name) + LEFT JOIN information_schema.columns actual ON actual.table_schema = 'public' + AND actual.table_name = expected.table_name AND actual.column_name = expected.column_name + WHERE actual.column_name IS NULL ORDER BY expected.table_name, expected.column_name`, [columnTables, columnNames]), + client.query(`SELECT conrelid::regclass::text AS table_name, conname + FROM pg_constraint WHERE connamespace = 'public'::regnamespace + AND contype IN ('f', 'c') AND NOT convalidated ORDER BY table_name, conname`), + client.query(`SELECT indexes.indexrelid::regclass::text AS index_name, indexes.indrelid::regclass::text AS table_name + FROM pg_index AS indexes + JOIN pg_class AS relation ON relation.oid = indexes.indrelid + JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'public' AND (NOT indexes.indisvalid OR NOT indexes.indisready) + ORDER BY table_name, index_name`), + client.query(COLUMN_FINGERPRINT_QUERY), + client.query(CONSTRAINT_FINGERPRINT_QUERY), + client.query(INDEX_FINGERPRINT_QUERY), + ]); + return { + transactionReadOnly: session.rows[0]?.transaction_read_only, + missingTables: missingTables.rows.map(row => row.table_name), + missingColumns: missingColumns.rows.map(row => `${row.table_name}.${row.column_name}`), + unvalidatedConstraints: unvalidatedConstraints.rows, + invalidIndexes: invalidIndexes.rows, + fingerprints: { + column: column.rows[0]?.fingerprint, + constraint: constraint.rows[0]?.fingerprint, + index: index.rows[0]?.fingerprint, + }, + }; +} + +async function collectTargetReport(connectionString, label, environment) { + const target = validateSchemaDriftTarget(connectionString, label); + const pool = new pg.Pool({ + connectionString, + application_name: SCHEMA_DRIFT_APPLICATION_NAME, + max: 1, + connectionTimeoutMillis: 10_000, + idleTimeoutMillis: 10_000, + statement_timeout: 30_000, + query_timeout: 30_000, + lock_timeout: 5_000, + idle_in_transaction_session_timeout: 15_000, + ssl: getVerifiedSchemaDriftSslConfig(environment), + }); + const client = await pool.connect(); + try { + await client.query("BEGIN TRANSACTION READ ONLY"); + const report = await collectSchemaReport(client); + await client.query("ROLLBACK"); + return { target: redactedTarget(target), ...report }; + } catch (error) { + try { await client.query("ROLLBACK"); } catch { /* Original error is actionable. */ } + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +export async function verifyStagingSchemaDrift(environment = process.env) { + const baselineUrl = environment.SCHEMA_DRIFT_BASELINE_DATABASE_URL; + const stagingUrl = environment.SCHEMA_DRIFT_STAGING_DATABASE_URL; + const baselineTarget = validateSchemaDriftTarget(baselineUrl, "SCHEMA_DRIFT_BASELINE_DATABASE_URL"); + const stagingTarget = validateSchemaDriftTarget(stagingUrl, "SCHEMA_DRIFT_STAGING_DATABASE_URL"); + requireDistinctTargets(baselineTarget, stagingTarget); + const sqlPath = join(dirname(fileURLToPath(import.meta.url)), "staging-schema-drift-and-synthetic-fixture-verification.sql"); + assertReadOnlyQueryPack(await readFile(sqlPath, "utf8")); + const [baseline, staging] = await Promise.all([ + collectTargetReport(baselineUrl, "SCHEMA_DRIFT_BASELINE_DATABASE_URL", environment), + collectTargetReport(stagingUrl, "SCHEMA_DRIFT_STAGING_DATABASE_URL", environment), + ]); + assertSchemaComparison(baseline, staging); + return { + status: "passed", + scope: "protected-staging-schema-drift-only", + baseline: baseline.target, + staging: staging.target, + fingerprints: baseline.fingerprints, + fixturePopulationVerified: false, + noReadinessCredit: true, + }; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + verifyStagingSchemaDrift().then(result => { + console.log(JSON.stringify(result, null, 2)); + }).catch(error => { + console.error(`Staging schema-drift verification failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/ci/verify-visual-baseline-review.mjs b/scripts/ci/verify-visual-baseline-review.mjs new file mode 100644 index 00000000..1ebb9148 --- /dev/null +++ b/scripts/ci/verify-visual-baseline-review.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Validates that visual snapshots were reviewed by independent accountable roles. + * The program is read-only: it never writes snapshots, reviews, pull requests, + * workflow state, environment configuration, or deployment state. + * + * Required environment: + * VISUAL_BASELINE_CONTRACT_PATH declarative 12-snapshot contract + * VISUAL_BASELINE_REVIEW_PATH approved review record + * VISUAL_SNAPSHOT_DIRECTORY committed Playwright baseline directory + * VISUAL_REVIEW_HEAD_COMMIT full pull-request source head SHA + * GITHUB_EVENT_PATH pull_request event payload + * GITHUB_REPOSITORY owner/repository + * GITHUB_TOKEN read-only pull-request metadata token + */ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; + +const FULL_SHA = /^[a-f0-9]{40}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const REQUIRED_ROLES = [ + "product-ux-owner", + "qa-lead", + "accessibility-owner", + "independent-engineering-reviewer", +]; + +function fail(message) { + throw new Error(message); +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (!value) fail(`${name} is required`); + return value; +} + +function readJson(file, label) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + fail( + `${label} could not be read: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function sha256(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function gitHubReviews(repository, prNumber) { + try { + const output = execFileSync( + "gh", + [ + "api", + `repos/${repository}/pulls/${prNumber}/reviews?per_page=100`, + "--paginate", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + NO_COLOR: "1", + CLICOLOR: "0", + GH_FORCE_TTY: "0", + }, + } + ); + return JSON.parse(output.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); + } catch (error) { + const detail = + error?.stderr?.toString().trim() || error?.message || String(error); + fail(`GitHub review read failed: ${detail}`); + } +} + +function verifyContract(contract) { + if (contract?.schemaVersion !== 1) + fail("visual baseline contract schemaVersion must equal 1"); + if (contract?.project !== "chromium") + fail("visual baseline contract must target chromium"); + if (contract?.matrix?.workers !== 1 || contract?.matrix?.retries !== 0) + fail("visual baseline contract must require one worker and zero retries"); + if (!Array.isArray(contract?.snapshots) || contract.snapshots.length !== 12) + fail("visual baseline contract must contain exactly 12 snapshots"); + const ids = new Set(); + const snapshots = new Set(); + for (const entry of contract.snapshots) { + if (typeof entry?.id !== "string" || !entry.id || ids.has(entry.id)) + fail( + "visual baseline contract contains a missing or duplicate snapshot id" + ); + if ( + typeof entry?.snapshot !== "string" || + !entry.snapshot.endsWith(".png") || + snapshots.has(entry.snapshot) + ) + fail( + "visual baseline contract contains a missing, non-PNG, or duplicate snapshot filename" + ); + if ( + !Number.isInteger(entry?.viewport?.width) || + !Number.isInteger(entry?.viewport?.height) || + typeof entry?.fullPage !== "boolean" + ) + fail( + `visual baseline contract entry ${entry.id} has invalid viewport/fullPage metadata` + ); + ids.add(entry.id); + snapshots.add(entry.snapshot); + } + return { ids, snapshots }; +} + +function verifyReview(review, captureCommit, expectedIds, event) { + if (review?.schemaVersion !== 2) + fail("visual baseline review schemaVersion must equal 2"); + if (review?.captureCommit !== captureCommit) + fail( + "visual baseline review captureCommit must equal VISUAL_REVIEW_CAPTURE_COMMIT" + ); + if ( + review?.pullRequest?.number !== event.pull_request.number || + review?.pullRequest?.captureCommit !== captureCommit + ) + fail( + "visual baseline review must bind to the pull request and the reviewed capture commit" + ); + if ( + review?.matrix?.project !== "chromium" || + review?.matrix?.workers !== 1 || + review?.matrix?.retries !== 0 + ) + fail( + "visual baseline review must record chromium, one worker, and zero retries" + ); + if (!Array.isArray(review?.snapshots) || review.snapshots.length !== 12) + fail("visual baseline review must contain exactly 12 snapshot decisions"); + const reviewedIds = new Set(); + const byId = new Map(); + for (const entry of review.snapshots) { + if (!expectedIds.has(entry?.id) || reviewedIds.has(entry.id)) + fail( + "visual baseline review has a missing, unknown, or duplicate snapshot decision" + ); + if (entry?.decision !== "accepted" || !SHA256.test(entry?.sha256 ?? "")) + fail( + `visual baseline review snapshot ${entry.id} is not accepted with a SHA-256 hash` + ); + if ( + typeof entry?.rationale !== "string" || + entry.rationale.trim().length < 12 + ) + fail( + `visual baseline review snapshot ${entry.id} needs a substantive rationale` + ); + reviewedIds.add(entry.id); + byId.set(entry.id, entry); + } + if (reviewedIds.size !== expectedIds.size) + fail("visual baseline review does not cover every declared snapshot"); + if ( + !Array.isArray(review?.approvals) || + review.approvals.length !== REQUIRED_ROLES.length + ) + fail( + "visual baseline review must contain exactly four accountable-role approvals" + ); + const roles = new Set(); + const actors = new Set(); + for (const approval of review.approvals) { + if (!REQUIRED_ROLES.includes(approval?.role) || roles.has(approval.role)) + fail("visual baseline review has a missing or duplicate approval role"); + if ( + typeof approval?.actor !== "string" || + !approval.actor || + approval.actor === event.pull_request.user.login || + actors.has(approval.actor) + ) + fail( + "visual baseline approvals must be by four distinct actors independent of the PR author" + ); + if ( + approval?.decision !== "accepted" || + !ISO_UTC.test(approval?.reviewedAt ?? "") || + !/^https:\/\//.test(approval?.evidenceUri ?? "") + ) + fail(`visual baseline approval for ${approval.role} is incomplete`); + roles.add(approval.role); + actors.add(approval.actor); + } + return { byId, actors }; +} + +function verifyCaptureIsAncestor(repository, captureCommit, candidate) { + if (captureCommit === candidate) return; + let comparison; + try { + const output = execFileSync( + "gh", + ["api", `repos/${repository}/compare/${captureCommit}...${candidate}`], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + NO_COLOR: "1", + CLICOLOR: "0", + GH_FORCE_TTY: "0", + }, + } + ); + comparison = JSON.parse(output.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")); + } catch (error) { + const detail = + error?.stderr?.toString().trim() || error?.message || String(error); + fail(`GitHub capture ancestry read failed: ${detail}`); + } + if (comparison?.status !== "ahead") { + fail( + "visual baseline captureCommit must be an ancestor of VISUAL_REVIEW_HEAD_COMMIT" + ); + } +} + +function verifyGitHubApprovals(reviews, actors, candidate) { + const atHead = new Set( + reviews + .filter( + review => + review?.state === "APPROVED" && review?.commit_id === candidate + ) + .map(review => review?.user?.login) + .filter(Boolean) + ); + for (const actor of actors) { + if (!atHead.has(actor)) + fail( + `visual baseline reviewer ${actor} has no GitHub APPROVED review at the current candidate commit` + ); + } +} + +function verifySnapshotHashes(contract, reviewById, snapshotDirectory) { + for (const entry of contract.snapshots) { + const file = resolve(snapshotDirectory, entry.snapshot); + if (!existsSync(file)) + fail(`approved visual baseline file is missing: ${entry.snapshot}`); + if (basename(file) !== entry.snapshot) + fail(`unsafe visual baseline filename: ${entry.snapshot}`); + if (sha256(file) !== reviewById.get(entry.id).sha256) + fail(`approved visual baseline hash mismatch: ${entry.snapshot}`); + } +} + +function main() { + const contractPath = resolve( + requiredEnvironment("VISUAL_BASELINE_CONTRACT_PATH") + ); + const reviewPath = resolve( + requiredEnvironment("VISUAL_BASELINE_REVIEW_PATH") + ); + const snapshotDirectory = resolve( + requiredEnvironment("VISUAL_SNAPSHOT_DIRECTORY") + ); + const candidate = requiredEnvironment("VISUAL_REVIEW_HEAD_COMMIT"); + if (!FULL_SHA.test(candidate)) + fail("VISUAL_REVIEW_HEAD_COMMIT must be a full lower-case SHA"); + const repository = requiredEnvironment("GITHUB_REPOSITORY"); + requiredEnvironment("GITHUB_TOKEN"); + const event = readJson( + requiredEnvironment("GITHUB_EVENT_PATH"), + "GitHub event payload" + ); + if ( + event?.pull_request?.head?.sha !== candidate || + !Number.isInteger(event?.pull_request?.number) || + typeof event?.pull_request?.user?.login !== "string" + ) + fail( + "pull_request event must bind to VISUAL_REVIEW_HEAD_COMMIT and author" + ); + + const contract = readJson(contractPath, "visual baseline contract"); + const review = readJson(reviewPath, "visual baseline review"); + const captureCommit = review?.captureCommit; + if (!FULL_SHA.test(captureCommit ?? "")) + fail("visual baseline review captureCommit must be a full lower-case SHA"); + const { ids } = verifyContract(contract); + const { byId, actors } = verifyReview(review, captureCommit, ids, event); + verifySnapshotHashes(contract, byId, snapshotDirectory); + verifyCaptureIsAncestor(repository, captureCommit, candidate); + verifyGitHubApprovals( + gitHubReviews(repository, event.pull_request.number), + actors, + candidate + ); + process.stdout.write( + `${JSON.stringify({ status: "passed", candidate, captureCommit, snapshots: ids.size, reviewers: actors.size }, null, 2)}\n` + ); +} + +try { + main(); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; +} diff --git a/scripts/ci/write-e2e-shard-manifest.mjs b/scripts/ci/write-e2e-shard-manifest.mjs new file mode 100644 index 00000000..dc2f8bd5 --- /dev/null +++ b/scripts/ci/write-e2e-shard-manifest.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Emits a minimal, non-secret manifest for one independently executed E2E shard. + * The caller supplies immutable candidate and job identity from CI. This script + * does not execute tests or grant release approval. + */ + +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +function required(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +async function main() { + const candidateCommit = required("E2E_CANDIDATE_COMMIT"); + const shard = required("E2E_SHARD"); + const project = required("E2E_PROJECT"); + const testRunId = required("E2E_TEST_RUN_ID"); + const outputDirectory = path.resolve(required("E2E_ARTIFACT_DIRECTORY")); + + if (!/^[a-f0-9]{40}$/i.test(candidateCommit)) { + throw new Error("E2E_CANDIDATE_COMMIT must be a full 40-character Git commit SHA"); + } + if (!/^[a-z0-9-]+$/i.test(shard)) throw new Error("E2E_SHARD contains unsupported characters"); + if (!/^[a-z0-9-]+$/i.test(project)) throw new Error("E2E_PROJECT contains unsupported characters"); + if (!/^[a-z0-9._-]+$/i.test(testRunId)) throw new Error("E2E_TEST_RUN_ID contains unsupported characters"); + + await mkdir(outputDirectory, { recursive: true }); + const outputPath = path.join(outputDirectory, "e2e-shard-manifest.json"); + const temporaryPath = `${outputPath}.tmp-${process.pid}`; + const manifest = { + schemaVersion: 1, + candidateCommit, + shard, + project, + testRunId, + createdAt: new Date().toISOString(), + }; + await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await rename(temporaryPath, outputPath); + process.stdout.write(`${JSON.stringify(manifest)}\n`); +} + +main().catch(error => { + process.stderr.write(`E2E shard manifest generation failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/fixtures/seed_remaining_synthetic_fixtures.sql b/scripts/fixtures/seed_remaining_synthetic_fixtures.sql new file mode 100644 index 00000000..26485140 --- /dev/null +++ b/scripts/fixtures/seed_remaining_synthetic_fixtures.sql @@ -0,0 +1,274 @@ +-- Synthetic fixture extension for tables not covered by the maintained Drizzle seed path. +-- +-- SAFETY BOUNDARY: this script is for an explicitly confirmed non-production +-- synthetic database only. It creates no release, security, residency, +-- operational, compliance, or regulatory evidence. The companion Node runner +-- enforces the target and confirmation guard before this SQL is executed. +-- +-- The script is idempotent and intentionally contains no transaction control; +-- scripts/seed-remaining-synthetic-fixtures.mjs owns the transaction. + +DO $$ +DECLARE + missing_tables text[]; +BEGIN + SELECT array_agg(table_name ORDER BY table_name) + INTO missing_tables + FROM (VALUES + ('analytics_snapshots'), + ('article40_codes'), + ('dpco_audit_service_records'), + ('dpco_registry_service_records'), + ('dpco_verification_service_records'), + ('dt_jurisdictions'), + ('dt_monte_carlo_stats'), + ('dt_org_agents'), + ('dt_policies'), + ('dt_policy_impacts'), + ('dt_sandboxes'), + ('dt_simulation_results'), + ('dt_simulations'), + ('marketplace_plugins'), + ('mobile_push_devices'), + ('noc_agent_memory'), + ('onboarding_checklists'), + ('users') + ) AS required(table_name) + WHERE to_regclass(format('public.%I', table_name)) IS NULL; + + IF missing_tables IS NOT NULL THEN + RAISE EXCEPTION + 'Synthetic fixture schema is incomplete; missing public table(s): %. Apply the checked-in Drizzle migration journal before running fixtures.', + array_to_string(missing_tables, ', '); + END IF; +END +$$; + +-- Runtime contracts: an aggregate-only metric with a non-null conflict key. +INSERT INTO analytics_snapshots ( + metric_name, dimension, dimension_value, metric_value, snapshot_date, metadata +) VALUES ( + 'synthetic_fixture_coverage', 'scenario', '2026-08-31', 1.0000, DATE '2026-08-31', + '{"synthetic":true,"scenario":"fixture-coverage","not_production_evidence":true}'::jsonb +) +ON CONFLICT (metric_name, dimension, dimension_value, snapshot_date) DO UPDATE SET + metric_value = EXCLUDED.metric_value, + metadata = EXCLUDED.metadata; + +INSERT INTO article40_codes ( + code_name, sector, description, submitted_by, document_url, status, approved_by, approval_date +) +SELECT + 'SYNTHETIC-ARTICLE40-DEMO', 'synthetic-testing', + 'Fixture-only Article 40 demonstration code; it is not a regulatory submission or approval.', + 'synthetic-fixture-runner', 'https://example.invalid/ndsep/synthetic/article40', + 'draft', NULL, NULL +WHERE NOT EXISTS ( + SELECT 1 FROM article40_codes + WHERE code_name = 'SYNTHETIC-ARTICLE40-DEMO' AND sector = 'synthetic-testing' +); + +-- DPCO service boundary: stable UUIDs and required lifecycle identifiers in JSON. +INSERT INTO dpco_audit_service_records (audit_id, payload) +VALUES ( + '00000000-0000-4000-8000-000000000035'::uuid, + '{"synthetic":true,"dpco_org_id":"synthetic-dpco-001","status":"completed","audit_reference":"SYNTHETIC-AUDIT-001"}'::jsonb +) +ON CONFLICT (audit_id) DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW(); + +INSERT INTO dpco_registry_service_records (registry_id, payload) +VALUES ( + '00000000-0000-4000-8000-000000000036'::uuid, + '{"synthetic":true,"dpco_org_id":"synthetic-dpco-001","status":"active","licence_number":"SYNTHETIC-DPCO-LIC-001"}'::jsonb +) +ON CONFLICT (registry_id) DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW(); + +INSERT INTO dpco_verification_service_records (statement_id, payload) +VALUES ( + '00000000-0000-4000-8000-000000000037'::uuid, + '{"synthetic":true,"dpco_org_id":"synthetic-dpco-001","status":"issued","ref_number":"SYNTHETIC-VERIFY-001"}'::jsonb +) +ON CONFLICT (statement_id) DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW(); + +-- Digital-twin parent records. Values are intentionally labelled scenario data, +-- never real economic, policy, regulator, or organization assertions. +INSERT INTO dt_jurisdictions ( + code, name, region, data_protection_act, regulator, adequacy_status, + population_millions, gdp_usd_billions, digital_economy_pct +) VALUES ( + 'SYNTH', 'Synthetic Fixture Jurisdiction', 'Test Region', + 'Fixture Policy Only', 'Synthetic Test Authority', 'none', 0, 0, 0 +) +ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + region = EXCLUDED.region, + data_protection_act = EXCLUDED.data_protection_act, + regulator = EXCLUDED.regulator, + adequacy_status = EXCLUDED.adequacy_status, + updated_at = NOW(); + +INSERT INTO dt_policies ( + jurisdiction_id, code, name, category, status, effective_date, rules, parameters, created_by +) +SELECT + jurisdiction.id, 'SYNTHETIC-FIXTURE-POLICY-001', 'Synthetic Fixture Policy', + 'consent', 'draft', DATE '2026-08-31', + '[{"target_sector":"synthetic-testing","metric":"fixture_coverage","operator":">=","threshold":1}]'::jsonb, + '{"synthetic":true,"not_regulatory_evidence":true}'::jsonb, + 'synthetic-fixture-runner' +FROM dt_jurisdictions AS jurisdiction +WHERE jurisdiction.code = 'SYNTH' + AND NOT EXISTS (SELECT 1 FROM dt_policies WHERE code = 'SYNTHETIC-FIXTURE-POLICY-001'); + +INSERT INTO dt_org_agents ( + jurisdiction_id, org_name, sector, compliance_score, security_budget_usd, + infosec_staff, tech_maturity, risk_appetite, breach_history, penalty_history, + data_volume_gb, cross_border, last_audit_date, metadata +) +SELECT + jurisdiction.id, 'Synthetic Fixture Organization', 'synthetic-testing', 75.00, 1000.00, + 2, 5.0, 3.0, 0, 0, 10.00, FALSE, DATE '2026-08-31', + '{"synthetic":true,"not_an_organization_attestation":true}'::jsonb +FROM dt_jurisdictions AS jurisdiction +WHERE jurisdiction.code = 'SYNTH' + AND NOT EXISTS (SELECT 1 FROM dt_org_agents WHERE org_name = 'Synthetic Fixture Organization'); + +INSERT INTO dt_simulations ( + simulation_id, name, type, jurisdictions, policies, parameters, duration_months, + iterations, status, started_at, completed_at, created_by +) VALUES ( + 'NDSEP-SYNTHETIC-FIXTURE-001', 'Synthetic Fixture Coverage Scenario', 'scenario', + '["SYNTH"]'::jsonb, '["SYNTHETIC-FIXTURE-POLICY-001"]'::jsonb, + '{"synthetic":true,"random_seed":20260831,"not_policy_evidence":true}'::jsonb, + 1, 1, 'completed', '2026-08-31T00:00:00.000Z'::timestamptz, + '2026-08-31T00:01:00.000Z'::timestamptz, 'synthetic-fixture-runner' +) +ON CONFLICT (simulation_id) DO UPDATE SET + name = EXCLUDED.name, + jurisdictions = EXCLUDED.jurisdictions, + policies = EXCLUDED.policies, + parameters = EXCLUDED.parameters, + status = EXCLUDED.status, + completed_at = EXCLUDED.completed_at; + +INSERT INTO dt_simulation_results ( + simulation_id, jurisdiction, month, iteration, avg_compliance, breach_count, + total_penalties_local, cross_border_flows, gdp_impact_pct, fdi_confidence, + insurance_cost_idx, sector_data +) +SELECT + 'NDSEP-SYNTHETIC-FIXTURE-001', 'SYNTH', 1, 1, 75.00, 0, + 0, 0, 0.000, 50.00, 100.00, + '{"synthetic-testing":{"compliance":75.00,"records":1}}'::jsonb +WHERE NOT EXISTS ( + SELECT 1 FROM dt_simulation_results + WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND jurisdiction = 'SYNTH' AND month = 1 AND iteration = 1 +); + +INSERT INTO dt_monte_carlo_stats ( + simulation_id, jurisdiction, month, metric, p5, p25, p50, p75, p95, mean, std_dev +) +SELECT + 'NDSEP-SYNTHETIC-FIXTURE-001', 'SYNTH', 1, 'compliance', + 70.0000, 72.0000, 75.0000, 78.0000, 80.0000, 75.0000, 2.5000 +WHERE NOT EXISTS ( + SELECT 1 FROM dt_monte_carlo_stats + WHERE simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND jurisdiction = 'SYNTH' + AND month = 1 AND metric = 'compliance' +); + +INSERT INTO dt_policy_impacts ( + simulation_id, policy_id, jurisdiction, sector, compliance_delta, breach_delta_pct, + penalty_delta_local, cost_benefit_ratio, effectiveness_score, sensitivity_rank, recommendations +) +SELECT + 'NDSEP-SYNTHETIC-FIXTURE-001', policy.id, 'SYNTH', 'synthetic-testing', + 5.00, -10.00, 0, 1.0000, 75.00, 1, + '["Synthetic scenario only; do not use for regulatory, financial, or operational decisions."]'::jsonb +FROM dt_policies AS policy +WHERE policy.code = 'SYNTHETIC-FIXTURE-POLICY-001' + AND NOT EXISTS ( + SELECT 1 FROM dt_policy_impacts AS impact + WHERE impact.simulation_id = 'NDSEP-SYNTHETIC-FIXTURE-001' AND impact.policy_id = policy.id + ); + +INSERT INTO dt_sandboxes ( + sandbox_id, name, description, base_snapshot, policies_applied, status, created_by, expires_at +) VALUES ( + 'NDSEP-SYNTHETIC-SANDBOX-001', 'Synthetic Fixture Sandbox', + 'Fixture-only simulation sandbox; it contains no production state or authorization.', + '{"synthetic":true,"captured_at":"2026-08-31T00:00:00.000Z"}'::jsonb, + '["SYNTHETIC-FIXTURE-POLICY-001"]'::jsonb, 'active', 'synthetic-fixture-runner', + '2027-08-31T00:00:00.000Z'::timestamptz +) +ON CONFLICT (sandbox_id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + base_snapshot = EXCLUDED.base_snapshot, + policies_applied = EXCLUDED.policies_applied, + expires_at = EXCLUDED.expires_at; + +-- Marketplace and native-mobile boundaries: fake identifiers and no routable URL/token. +INSERT INTO marketplace_plugins ( + name, description, author, version, sector, category, install_count, status, manifest +) VALUES ( + 'Synthetic Fixture Policy Connector', + 'Fixture-only marketplace catalog item. It has no executable URL, secret, or network capability.', + 'synthetic-fixture-runner', '0.0.0-fixture', 'synthetic-testing', 'testing', 0, 'published', + '{"synthetic":true,"network":"disabled","capabilities":[]}'::jsonb +) +ON CONFLICT (name) DO UPDATE SET + description = EXCLUDED.description, + author = EXCLUDED.author, + version = EXCLUDED.version, + sector = EXCLUDED.sector, + category = EXCLUDED.category, + install_count = EXCLUDED.install_count, + status = EXCLUDED.status, + manifest = EXCLUDED.manifest; + +INSERT INTO mobile_push_devices (user_id, token, platform, device_id) +VALUES ( + 'synthetic-user-001', 'ndsep-synthetic-token-do-not-use-001', 'web', 'synthetic-device-001' +) +ON CONFLICT (user_id, device_id) DO UPDATE SET + token = EXCLUDED.token, + platform = EXCLUDED.platform, + updated_at = NOW(); + +-- NOC agent memory: a bounded synthetic learning record, not an operational event. +INSERT INTO noc_agent_memory ( + memory_id, memory_type, category, title, description, context, confidence, + usage_count, tags, embedding_vector +) VALUES ( + 'SYNTHETIC-NOC-MEMORY-001', 'service_baseline', 'synthetic-testing', + 'Synthetic fixture baseline', + 'Fixture-only NOC memory demonstrating schema shape; it is not an observed production incident or baseline.', + '{"synthetic":true,"service":"fixture-service","not_operational_evidence":true}'::jsonb, + 0.5000, 0, ARRAY['synthetic','fixture'], '[0.0,0.0,0.0]'::jsonb +) +ON CONFLICT (memory_id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + context = EXCLUDED.context, + confidence = EXCLUDED.confidence, + tags = EXCLUDED.tags, + embedding_vector = EXCLUDED.embedding_vector, + updated_at = NOW(); + +-- Onboarding: create/select a fake-only account, then attach a completed fixture step. +INSERT INTO users (open_id, name, role, created_at, updated_at, last_signed_in) +VALUES ( + 'synthetic-onboarding-user-001', 'Synthetic Onboarding User', 'user', + '2026-08-31T00:00:00.000Z'::timestamp, '2026-08-31T00:00:00.000Z'::timestamp, + '2026-08-31T00:00:00.000Z'::timestamp +) +ON CONFLICT (open_id) DO UPDATE SET + name = EXCLUDED.name, + updated_at = EXCLUDED.updated_at; + +INSERT INTO onboarding_checklists (user_id, step_id, completed_at) +SELECT user_record.id, 'synthetic_fixture_completed', '2026-08-31T00:00:00.000Z'::timestamptz +FROM users AS user_record +WHERE user_record.open_id = 'synthetic-onboarding-user-001' +ON CONFLICT (user_id, step_id) DO UPDATE SET completed_at = EXCLUDED.completed_at; diff --git a/scripts/fixtures/uncovered-table-scenario-manifest.json b/scripts/fixtures/uncovered-table-scenario-manifest.json new file mode 100644 index 00000000..24e48639 --- /dev/null +++ b/scripts/fixtures/uncovered-table-scenario-manifest.json @@ -0,0 +1,111 @@ +{ + "schemaVersion": 1, + "purpose": "Synthetic scenario planning only. This manifest does not create release, compliance, residency, staging, or production evidence.", + "fixtureRequired": { + "ai_ethics_reviews": "Synthetic AI governance review with known model, risk category, review outcome, and accountable synthetic reviewer.", + "airflow_dags": "Synthetic DAG metadata and run-health state; never scheduler credentials or real schedules.", + "analytics_events": "Synthetic aggregated product events with no direct identifiers.", + "analytics_snapshots": "Synthetic dated aggregate snapshots linked to synthetic organizations.", + "api_usage_log": "Synthetic API access records using documentation-reserved addresses and fixture identities.", + "article40_codes": "Static synthetic reference codes and descriptions for Article 40 workflow display.", + "breach_timers": "Synthetic breach deadline records linked to synthetic breach incidents.", + "bulk_operations": "Synthetic bulk-operation lifecycle records with bounded row counts and audit correlation.", + "clinical_trials": "Synthetic trial registry records only; no patient, investigator, or sponsor personal data.", + "compliance_gap_assessments": "Synthetic gap assessments with explicit findings and remediation status.", + "compliance_timeline": "Synthetic timeline events linked to a seeded compliance lifecycle.", + "consent_audit_chain": "Synthetic append-only consent audit events with deterministic fixture hashes.", + "consent_lifecycle_events": "Synthetic grant, amendment, withdrawal, and expiry lifecycle events.", + "cross_agency_data_shares": "Synthetic inter-agency request/approval/transfer records with no real agencies or data subjects.", + "data_lineage_edges": "Synthetic lineage edges referencing synthetic lineage nodes.", + "data_lineage_nodes": "Synthetic data lineage assets and processing nodes.", + "data_pipeline_flows": "Synthetic pipeline definitions, statuses, and ownership metadata.", + "dbt_models": "Synthetic transformation-model metadata and test status, without warehouse credentials.", + "dpco_audit_service_records": "Synthetic DPCO service records linked to synthetic engagement/client fixtures.", + "dpco_registry_service_records": "Synthetic DPCO registry service records with fictional registration identifiers.", + "dpco_verification_service_records": "Synthetic verification service records with fixture-only source identifiers.", + "dt_bilateral_agreements": "Synthetic digital-twin agreement metadata; no legal assertion or real counterparty.", + "dt_economic_indicators": "Clearly labeled synthetic values for visualization only, never policy or financial evidence.", + "dt_jurisdictions": "Synthetic jurisdiction/reference records with no claim of regulatory approval.", + "dt_monte_carlo_stats": "Deterministic synthetic simulation outputs with seed and model version metadata.", + "dt_org_agents": "Synthetic agent health and assignment records using fixture organization IDs.", + "dt_policies": "Synthetic digital-twin policy metadata and version lifecycle.", + "dt_policy_impacts": "Synthetic policy-impact scenarios referencing synthetic policies and indicators.", + "dt_sandboxes": "Synthetic sandbox configuration metadata; never operational access credentials.", + "dt_sector_models": "Synthetic sector-model metadata and model versions.", + "dt_simulation_results": "Deterministic synthetic simulation results tied to a fixture simulation.", + "dt_simulations": "Synthetic simulation definitions with fixture-only assumptions.", + "encryption_key_audit": "Synthetic key-audit metadata only; no private keys, KMS references, or cryptographic material.", + "encryption_key_metadata": "Synthetic key metadata using non-secret fixture identifiers and algorithm labels only.", + "feature_flag_audit": "Synthetic configuration-change audit events linked to synthetic flags.", + "feature_flags": "Synthetic non-production feature flags with disabled-by-default sensitive capabilities.", + "form_drafts": "Synthetic incomplete submissions containing no personal data or production request content.", + "generated_reports": "Synthetic report metadata with fixture storage URLs and clearly marked demo content.", + "grid_monitoring_events": "Synthetic grid-monitoring events using fictional asset IDs and documentation-reserved network values.", + "incident_playbooks": "Synthetic incident-response playbook metadata and non-production procedures.", + "incident_response_activations": "Synthetic closed activation timelines linked to fixture playbooks.", + "interconnect_disputes": "Synthetic dispute lifecycle entries with fictional parties and references.", + "lawful_intercept_requests": "Synthetic non-actionable request records; no targets, actual legal references, or real communications data.", + "liveness_checks": "Synthetic historical health checks; not evidence of a deployed service.", + "marketplace_plugins": "Synthetic plugin catalog metadata with no executable URLs or credentials.", + "mobile_push_devices": "Synthetic device registrations using non-routable tokens and fixture user IDs.", + "national_id_verifications": "Synthetic verification outcomes with invalid/test identifiers only, never BVN/NIN or real identities.", + "noc_agent_actions": "Synthetic NOC action records tied to synthetic incidents and agents.", + "noc_agent_memory": "Synthetic NOC memory summaries with no customer content or secrets.", + "noc_agent_predictions": "Synthetic prediction outputs with model version and confidence fields.", + "noc_alerts": "Synthetic NOC alerts covering open, acknowledged, and resolved lifecycles.", + "noc_collector_metrics": "Synthetic collector metric snapshots with bounded, labeled values.", + "noc_devices": "Synthetic device inventory using RFC 5737 documentation addresses and fictional serials.", + "noc_escalation_history": "Synthetic escalation events linked to fixture alerts and policies.", + "noc_escalation_policies": "Synthetic escalation-policy metadata and notification routes.", + "noc_incident_knowledge": "Synthetic operational knowledge records with no production host names or access steps.", + "noc_oncall_schedules": "Synthetic schedule rotations with fictional users and no contact data.", + "noc_remediation_history": "Synthetic completed remediation records linked to fixture incidents.", + "noc_runbooks": "Synthetic runbook metadata and non-production recovery instructions.", + "noc_service_baselines": "Synthetic baseline values labeled as fixture data, not performance SLO evidence.", + "noc_topology_links": "Synthetic topology links referencing fixture devices only.", + "noc_uptime_records": "Synthetic uptime history marked as demonstration data, not SLA evidence.", + "noc_uptime_sla": "Synthetic SLA configuration without a claim of actual service attainment.", + "oil_gas_data_reports": "Synthetic sector-report metadata with fictional asset and organization references.", + "onboarding_checklists": "Synthetic onboarding checklist lifecycle records tied to fixture organizations.", + "patient_data_localisation_checks": "Synthetic localisation checks using fictional facilities and datasets; no patient data or location attestation.", + "pia_assessments": "Synthetic privacy-impact assessments with fixture stakeholders and findings.", + "push_notification_log": "Synthetic delivery attempts with fake device tokens and fixture message IDs.", + "push_subscriptions": "Synthetic push subscriptions using non-routable endpoints and non-secret fixture keys.", + "regulatory_intelligence_items": "Synthetic regulatory-intelligence records clearly marked as demonstrations, not legal advice or an official feed.", + "regulatory_sandbox_applications": "Synthetic sandbox applications with fictional applicants and no authorization claims.", + "report_schedules": "Synthetic report schedule metadata with disabled delivery and fixture recipients only.", + "retention_purge_log": "Synthetic retention-purge audit records referencing fixture assets and records." + }, + "runtimeOwnedExemptions": { + "enforcement_summary": { + "ownerRole": "Application and data engineering", + "reason": "Derived enforcement read model; empty state is valid until synthetic enforcement events are projected.", + "expiresAt": "2026-12-31T00:00:00.000Z", + "readinessCredit": false + }, + "event_projections": { + "ownerRole": "Application and data engineering", + "reason": "Derived event projection store populated only by the scenario event producer; no direct row fixture is permitted.", + "expiresAt": "2026-12-31T00:00:00.000Z", + "readinessCredit": false + }, + "event_snapshots": { + "ownerRole": "Application and data engineering", + "reason": "Derived event snapshots are created by scenario replay; a direct fixture would bypass the snapshot business logic.", + "expiresAt": "2026-12-31T00:00:00.000Z", + "readinessCredit": false + }, + "event_store": { + "ownerRole": "Application and data engineering", + "reason": "Append-only event store must be populated via the tested scenario command path, not by direct SQL rows.", + "expiresAt": "2026-12-31T00:00:00.000Z", + "readinessCredit": false + }, + "field_encryption_status": { + "ownerRole": "Security and data engineering", + "reason": "Runtime-generated encryption-health observation; direct fixture rows could be mistaken for encryption evidence.", + "expiresAt": "2026-12-31T00:00:00.000Z", + "readinessCredit": false + } + } +} diff --git a/scripts/lib/synthetic-fixture-plan.mjs b/scripts/lib/synthetic-fixture-plan.mjs new file mode 100644 index 00000000..02c8ca0a --- /dev/null +++ b/scripts/lib/synthetic-fixture-plan.mjs @@ -0,0 +1,123 @@ +import { readFile } from "node:fs/promises"; + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function canonicalTimestamp(value, field) { + if (typeof value !== "string") throw new Error(`${field} must be a canonical UTC ISO timestamp`); + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) { + throw new Error(`${field} must be a canonical UTC ISO timestamp`); + } + return parsed; +} + +function sortedKeys(record) { + return Object.keys(record).sort((left, right) => left.localeCompare(right)); +} + +/** + * Validates the source-controlled plan that distinguishes synthetic fixture + * tables from a small set of runtime-owned, no-credit exemptions. It does not + * approve an exemption and cannot create production or compliance evidence. + */ +export function validateSyntheticFixturePlan(manifest) { + if (!isPlainObject(manifest) || manifest.schemaVersion !== 1) { + throw new Error("Synthetic fixture manifest must be an object with schemaVersion 1"); + } + const { fixtureRequired, runtimeOwnedExemptions } = manifest; + if (!isPlainObject(fixtureRequired) || !isPlainObject(runtimeOwnedExemptions)) { + throw new Error("Synthetic fixture manifest requires fixtureRequired and runtimeOwnedExemptions objects"); + } + + const fixtureTables = sortedKeys(fixtureRequired); + const exemptTables = sortedKeys(runtimeOwnedExemptions); + if (fixtureTables.length === 0) throw new Error("Synthetic fixture manifest must require at least one table fixture"); + if (new Set([...fixtureTables, ...exemptTables]).size !== fixtureTables.length + exemptTables.length) { + throw new Error("A table cannot be both fixture-required and runtime-exempt"); + } + for (const table of fixtureTables) { + if (!/^[a-z][a-z0-9_]*$/.test(table) || typeof fixtureRequired[table] !== "string" || fixtureRequired[table].trim().length < 24) { + throw new Error(`Invalid synthetic fixture requirement for table '${table}'`); + } + } + for (const table of exemptTables) { + const exemption = runtimeOwnedExemptions[table]; + if (!/^[a-z][a-z0-9_]*$/.test(table) || !isPlainObject(exemption)) { + throw new Error(`Invalid runtime-owned exemption for table '${table}'`); + } + if (typeof exemption.ownerRole !== "string" || exemption.ownerRole.trim().length < 3) { + throw new Error(`Runtime-owned exemption '${table}' requires an ownerRole`); + } + if (typeof exemption.reason !== "string" || exemption.reason.trim().length < 24) { + throw new Error(`Runtime-owned exemption '${table}' requires a detailed reason`); + } + if (exemption.readinessCredit !== false) { + throw new Error(`Runtime-owned exemption '${table}' must explicitly deny readiness credit`); + } + canonicalTimestamp(exemption.expiresAt, `Runtime-owned exemption '${table}' expiresAt`); + } + return { fixtureTables, exemptTables }; +} + +export async function loadSyntheticFixturePlan(path) { + let raw; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + throw new Error(`Unable to read synthetic fixture manifest: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); + } + let manifest; + try { + manifest = JSON.parse(raw); + } catch (error) { + throw new Error(`Synthetic fixture manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); + } + validateSyntheticFixturePlan(manifest); + return manifest; +} + +/** + * Evaluates a live public-table coverage summary against the manifest. This is + * a non-production development/test reporting layer: `scenarioComplete` does + * not mean every table is populated and must never be used as readiness proof. + */ +export function evaluateSyntheticFixtureCoverage(coverageSummary, manifest, now = new Date()) { + if (!coverageSummary || !Array.isArray(coverageSummary.emptyTables) || !coverageSummary.emptyTables.every(table => typeof table === "string")) { + throw new Error("Synthetic fixture coverage requires a valid public-table coverage summary"); + } + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new Error("Synthetic fixture coverage requires a valid evaluation time"); + + const { fixtureTables, exemptTables } = validateSyntheticFixturePlan(manifest); + const required = new Set(fixtureTables); + const exemptions = manifest.runtimeOwnedExemptions; + const exempt = new Set(exemptTables); + const emptyTables = [...new Set(coverageSummary.emptyTables)].sort((left, right) => left.localeCompare(right)); + const missingFixtureTables = emptyTables.filter(table => required.has(table)); + const runtimeOwnedEmptyTables = emptyTables.filter(table => exempt.has(table)); + const unplannedEmptyTables = emptyTables.filter(table => !required.has(table) && !exempt.has(table)); + const expiredRuntimeExemptions = exemptTables.filter(table => canonicalTimestamp(exemptions[table].expiresAt, `Runtime-owned exemption '${table}' expiresAt`).getTime() <= now.getTime()); + + return { + allPublicTablesPopulated: emptyTables.length === 0, + scenarioComplete: missingFixtureTables.length === 0 && unplannedEmptyTables.length === 0 && expiredRuntimeExemptions.length === 0, + missingFixtureTables, + runtimeOwnedEmptyTables, + unplannedEmptyTables, + expiredRuntimeExemptions, + noReadinessCredit: true, + }; +} + +export function requireSyntheticFixtureScenario(report) { + if (!report?.scenarioComplete) { + const issues = [ + ...(report?.missingFixtureTables?.map(table => `missing fixture: ${table}`) ?? []), + ...(report?.unplannedEmptyTables?.map(table => `unplanned empty table: ${table}`) ?? []), + ...(report?.expiredRuntimeExemptions?.map(table => `expired exemption: ${table}`) ?? []), + ]; + throw new Error(`Synthetic fixture scenario is incomplete; ${issues.join(", ") || "unknown evaluation failure"}`); + } + return report; +} diff --git a/scripts/lib/synthetic-seed-safety.mjs b/scripts/lib/synthetic-seed-safety.mjs new file mode 100644 index 00000000..008af709 --- /dev/null +++ b/scripts/lib/synthetic-seed-safety.mjs @@ -0,0 +1,179 @@ +import { URL } from "node:url"; + +export const SYNTHETIC_SEED_CONFIRMATION = "NDSEP_SYNTHETIC_DATA_ONLY"; +export const SYNTHETIC_SEED_APPLICATION_NAME = "ndsep-synthetic-seed"; + +const PRODUCTION_DATABASE_NAMES = /(?:^|[-_.])(prod(?:uction)?|live)(?:$|[-_.])/i; +const SYNTHETIC_DATABASE_NAMES = /(?:synthetic|demo|test|sandbox|staging)/i; + +function parseBoundedInteger(value, fallback, minimum, maximum, name) { + if (value === undefined || value === "") return fallback; + if (!/^\d+$/.test(value)) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`); + } + const parsed = Number(value); + if (parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be between ${minimum} and ${maximum}`); + } + return parsed; +} + +function databaseName(url) { + return decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] ?? ""); +} + +function isLoopbackHost(hostname) { + const normalized = hostname.toLowerCase(); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; +} + +/** + * Validates that a synthetic seed run is explicitly confirmed, never targets a + * production-labelled database, and has bounded database client settings. + * This guard deliberately does not accept a default credential or connection URL. + */ +export function getSyntheticSeedPoolOptions(environment = process.env) { + if (environment.NODE_ENV === "production") { + throw new Error("Synthetic seed is prohibited when NODE_ENV=production"); + } + if (environment.SYNTHETIC_SEED_CONFIRMATION !== SYNTHETIC_SEED_CONFIRMATION) { + throw new Error( + `Synthetic seed requires SYNTHETIC_SEED_CONFIRMATION=${SYNTHETIC_SEED_CONFIRMATION}` + ); + } + + const connectionString = environment.POSTGRES_URL ?? environment.DATABASE_URL; + if (!connectionString) { + throw new Error("Synthetic seed requires DATABASE_URL or POSTGRES_URL; no default database is permitted"); + } + + let parsed; + try { + parsed = new URL(connectionString); + } catch { + throw new Error("Synthetic seed DATABASE_URL must be a valid PostgreSQL connection URL"); + } + if (!/^postgres(?:ql)?:$/.test(parsed.protocol)) { + throw new Error("Synthetic seed DATABASE_URL must use the PostgreSQL protocol"); + } + + const name = databaseName(parsed); + if (!name) throw new Error("Synthetic seed DATABASE_URL must include a database name"); + if (PRODUCTION_DATABASE_NAMES.test(name)) { + throw new Error("Synthetic seed refuses a production- or live-labelled database name"); + } + if (!SYNTHETIC_DATABASE_NAMES.test(name)) { + throw new Error("Synthetic seed database name must identify a synthetic, demo, test, sandbox, or staging database"); + } + if (!isLoopbackHost(parsed.hostname)) { + if (environment.SYNTHETIC_SEED_ALLOW_REMOTE !== SYNTHETIC_SEED_CONFIRMATION) { + throw new Error( + `Synthetic seed refuses non-loopback host '${parsed.hostname}' unless SYNTHETIC_SEED_ALLOW_REMOTE=${SYNTHETIC_SEED_CONFIRMATION}` + ); + } + const sslMode = parsed.searchParams.get("sslmode"); + if (!sslMode || !["require", "verify-ca", "verify-full"].includes(sslMode)) { + throw new Error("Synthetic seed requires sslmode=require, verify-ca, or verify-full for a remote target"); + } + } + + return { + connectionString, + application_name: SYNTHETIC_SEED_APPLICATION_NAME, + max: parseBoundedInteger(environment.SYNTHETIC_SEED_POOL_MAX, 2, 1, 10, "SYNTHETIC_SEED_POOL_MAX"), + connectionTimeoutMillis: parseBoundedInteger( + environment.SYNTHETIC_SEED_CONNECTION_TIMEOUT_MS, + 5000, + 100, + 60000, + "SYNTHETIC_SEED_CONNECTION_TIMEOUT_MS" + ), + idleTimeoutMillis: parseBoundedInteger( + environment.SYNTHETIC_SEED_IDLE_TIMEOUT_MS, + 10000, + 1000, + 60000, + "SYNTHETIC_SEED_IDLE_TIMEOUT_MS" + ), + statement_timeout: parseBoundedInteger( + environment.SYNTHETIC_SEED_STATEMENT_TIMEOUT_MS, + 30000, + 1000, + 120000, + "SYNTHETIC_SEED_STATEMENT_TIMEOUT_MS" + ), + query_timeout: parseBoundedInteger( + environment.SYNTHETIC_SEED_QUERY_TIMEOUT_MS, + 30000, + 1000, + 120000, + "SYNTHETIC_SEED_QUERY_TIMEOUT_MS" + ), + lock_timeout: parseBoundedInteger( + environment.SYNTHETIC_SEED_LOCK_TIMEOUT_MS, + 5000, + 100, + 60000, + "SYNTHETIC_SEED_LOCK_TIMEOUT_MS" + ), + idle_in_transaction_session_timeout: parseBoundedInteger( + environment.SYNTHETIC_SEED_IDLE_IN_TRANSACTION_TIMEOUT_MS, + 15000, + 1000, + 120000, + "SYNTHETIC_SEED_IDLE_IN_TRANSACTION_TIMEOUT_MS" + ), + maxUses: 10000, + allowExitOnIdle: true, + }; +} + +function quoteIdentifier(identifier) { + return `"${identifier.replaceAll('"', '""')}"`; +} + +/** + * Reads the live public schema and counts every ordinary table. The caller must + * use this after a seed run; an empty table is not silently ignored. + */ +export async function inspectPublicTableCoverage(client) { + const { rows: tables } = await client.query( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename" + ); + const coverage = []; + for (const row of tables) { + const table = row.tablename; + if (typeof table !== "string" || table.length === 0) { + throw new Error("Database returned an invalid public table name during seed verification"); + } + const { rows } = await client.query(`SELECT COUNT(*)::bigint AS count FROM ${quoteIdentifier(table)}`); + const value = rows[0]?.count; + if (!/^\d+$/.test(String(value))) { + throw new Error(`Unable to verify row count for public table '${table}'`); + } + coverage.push({ table, rows: Number(value) }); + } + return coverage; +} + +export function summarizePublicTableCoverage(coverage) { + if (!Array.isArray(coverage) || coverage.some(row => !row || typeof row.table !== "string" || !Number.isSafeInteger(row.rows) || row.rows < 0)) { + throw new Error("Invalid public-table coverage input"); + } + const emptyTables = coverage.filter(row => row.rows === 0).map(row => row.table); + return { + totalTables: coverage.length, + populatedTables: coverage.length - emptyTables.length, + totalRows: coverage.reduce((total, row) => total + row.rows, 0), + emptyTables, + complete: emptyTables.length === 0, + }; +} + +export function requireCompleteSyntheticSeed(summary) { + if (!summary?.complete) { + const empty = Array.isArray(summary?.emptyTables) ? summary.emptyTables.join(", ") : "unknown"; + throw new Error(`Synthetic seed coverage is incomplete; empty public tables: ${empty}`); + } + return summary; +} diff --git a/scripts/security/render-alertmanager-config.mjs b/scripts/security/render-alertmanager-config.mjs new file mode 100755 index 00000000..97784f8a --- /dev/null +++ b/scripts/security/render-alertmanager-config.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const REQUIRED_VALUES = [ + "SMTP_HOST", + "SMTP_FROM", + "SLACK_WEBHOOK_URL", + "PAGERDUTY_INTEGRATION_KEY", + "NITDA_COMPLIANCE_EMAIL", + "PLATFORM_URL", +]; + +function usage() { + console.error("usage: render-alertmanager-config.mjs "); + process.exit(64); +} + +function parseEnvironment(filePath) { + const values = Object.create(null); + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + for (const [index, rawLine] of lines.entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) throw new Error(`${filePath}:${index + 1}: expected KEY=value`); + const [, key, rawValue] = match; + const value = rawValue.trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + values[key] = value.slice(1, -1); + } else { + values[key] = value; + } + } + return values; +} + +function requireValue(values, key) { + const value = values[key]; + if (!value || /(?:CHANGE_ME|PLACEHOLDER|example\.ng|example\.com)/i.test(value)) { + throw new Error(`${key} must be supplied with an approved non-placeholder production value`); + } + return value; +} + +function validate(values) { + const checked = Object.fromEntries(REQUIRED_VALUES.map((key) => [key, requireValue(values, key)])); + if (!/^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+$/.test(checked.SLACK_WEBHOOK_URL)) { + throw new Error("SLACK_WEBHOOK_URL must be an HTTPS Slack incoming-webhook URL"); + } + if (!/^https:\/\/[a-z0-9.-]+(?::\d+)?(?:\/[^\s]*)?$/i.test(checked.PLATFORM_URL)) { + throw new Error("PLATFORM_URL must be an absolute HTTPS URL"); + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(checked.SMTP_FROM)) { + throw new Error("SMTP_FROM must be an email address"); + } + if (!/^[^\s:]+:\d{1,5}$/.test(checked.SMTP_HOST)) { + throw new Error("SMTP_HOST must be host:port without whitespace"); + } + if (checked.PAGERDUTY_INTEGRATION_KEY.length < 16) { + throw new Error("PAGERDUTY_INTEGRATION_KEY must have at least 16 characters"); + } + return checked; +} + +function escapeYamlDoubleQuoted(value) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); +} + +function main() { + if (process.argv.length !== 5) usage(); + const [, , templatePath, outputPath, environmentPath] = process.argv; + const template = readFileSync(templatePath, "utf8"); + const values = validate(parseEnvironment(environmentPath)); + let rendered = template; + for (const [key, value] of Object.entries(values)) { + const token = `__NDSEP_${key}__`; + if (!rendered.includes(token)) throw new Error(`template does not contain required token ${token}`); + rendered = rendered.replaceAll(token, escapeYamlDoubleQuoted(value)); + } + if (/__NDSEP_[A-Z0-9_]+__|\$\{[^}]+:-[^}]*\}|PLACEHOLDER/.test(rendered)) { + throw new Error("rendered Alertmanager configuration contains unresolved or unsafe placeholders"); + } + + const output = resolve(outputPath); + mkdirSync(dirname(output), { recursive: true, mode: 0o700 }); + writeFileSync(output, rendered, { encoding: "utf8", mode: 0o600 }); + chmodSync(output, 0o600); + console.log(`rendered Alertmanager configuration at ${output}`); +} + +try { + main(); +} catch (error) { + console.error(`Alertmanager configuration rendering failed: ${error.message}`); + process.exit(1); +} diff --git a/scripts/security/verify-production-image-lock.sh b/scripts/security/verify-production-image-lock.sh new file mode 100755 index 00000000..f6c48504 --- /dev/null +++ b/scripts/security/verify-production-image-lock.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Verify that a rendered production Compose configuration uses immutable OCI digests. +# Usage: verify-production-image-lock.sh +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +compose_file=$1 +if [[ ! -r "$compose_file" ]]; then + echo "cannot read rendered Compose file: $compose_file" >&2 + exit 66 +fi + +invalid=0 +image_count=0 +line_number=0 + +while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + if [[ "$line" =~ ^[[:space:]]*build:([[:space:]]|$) ]]; then + echo "$compose_file:$line_number: production configuration must not contain local build directives" >&2 + invalid=1 + fi + + if [[ "$line" =~ ^[[:space:]]*image:[[:space:]]*(.+)[[:space:]]*$ ]]; then + image=${BASH_REMATCH[1]} + image=${image%%[[:space:]]#*} + image=${image#\"} + image=${image%\"} + image=${image#\'} + image=${image%\'} + image_count=$((image_count + 1)) + + if [[ ! "$image" =~ ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ ]]; then + echo "$compose_file:$line_number: image must be an immutable lowercase OCI SHA-256 reference, found '$image'" >&2 + invalid=1 + fi + fi +done < "$compose_file" + +if [[ $image_count -eq 0 ]]; then + echo "$compose_file: no image declarations found" >&2 + exit 65 +fi + +if [[ $invalid -ne 0 ]]; then + exit 1 +fi + +echo "verified $image_count immutable OCI image digests in $compose_file" diff --git a/scripts/security/verify-release-image-evidence.sh b/scripts/security/verify-release-image-evidence.sh new file mode 100755 index 00000000..63059dce --- /dev/null +++ b/scripts/security/verify-release-image-evidence.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Validate and bind scan/SBOM evidence to one immutable OCI image before signing. +# Usage: +# verify-release-image-evidence.sh \ +# +set -euo pipefail + +if [[ $# -ne 6 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +image_name=$1 +image_digest=$2 +source_sha=$3 +trivy_report=$4 +sbom=$5 +output=$6 + +if [[ ! "$image_name" =~ ^[a-z0-9][a-z0-9._/-]*$ ]]; then + echo "image name must be lowercase and untagged: '$image_name'" >&2 + exit 65 +fi +if [[ ! "$image_digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "image digest must be a lowercase sha256 digest" >&2 + exit 65 +fi +if [[ ! "$source_sha" =~ ^[a-f0-9]{40}$ ]]; then + echo "source SHA must be a full lowercase Git SHA-1" >&2 + exit 65 +fi +for required_file in "$trivy_report" "$sbom"; do + if [[ ! -s "$required_file" ]]; then + echo "required release evidence is missing or empty: $required_file" >&2 + exit 66 + fi +done + +if ! jq -e 'type == "object" and (.bomFormat == "CycloneDX" or .spdxVersion != null)' "$sbom" >/dev/null; then + echo "SBOM must be a CycloneDX or SPDX JSON document: $sbom" >&2 + exit 65 +fi + +high_critical_count=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH" or .Severity == "CRITICAL")] | length' "$trivy_report") +if [[ "$high_critical_count" != "0" ]]; then + echo "release image scan found $high_critical_count HIGH/CRITICAL vulnerability findings" >&2 + exit 1 +fi + +output_dir=$(dirname "$output") +mkdir -p "$output_dir" +trivy_sha256=$(sha256sum "$trivy_report" | awk '{print $1}') +sbom_sha256=$(sha256sum "$sbom" | awk '{print $1}') +generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +image_ref="${image_name}@${image_digest}" + +jq -n \ + --arg generated_at "$generated_at" \ + --arg image_name "$image_name" \ + --arg image_digest "$image_digest" \ + --arg image_ref "$image_ref" \ + --arg source_sha "$source_sha" \ + --arg trivy_report "$trivy_report" \ + --arg trivy_sha256 "$trivy_sha256" \ + --arg sbom "$sbom" \ + --arg sbom_sha256 "$sbom_sha256" \ + --argjson high_critical_count "$high_critical_count" \ + '{schema:"ndsep.release-evidence.v1", generated_at:$generated_at, image:{name:$image_name,digest:$image_digest,reference:$image_ref}, source_sha:$source_sha, trivy:{report:$trivy_report,sha256:$trivy_sha256,high_critical_count:$high_critical_count}, sbom:{path:$sbom,sha256:$sbom_sha256}}' \ + > "$output" + +echo "verified release evidence for $image_ref; output=$output" diff --git a/scripts/security/verify-staging-rollback.ts b/scripts/security/verify-staging-rollback.ts new file mode 100644 index 00000000..14d90c8c --- /dev/null +++ b/scripts/security/verify-staging-rollback.ts @@ -0,0 +1,479 @@ +#!/usr/bin/env tsx +/** + * Staging-only post-rollback verification. + * + * This program performs no deploy, traffic-switch, replay, offset mutation, schema + * mutation, or data mutation. It refuses non-staging URLs and requires a dedicated + * read-only PostgreSQL principal. It is intended for a protected staging runner after + * an approved rollback controller has captured immutable workload/traffic state. + */ + +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Pool, type PoolClient } from "pg"; + +type CheckStatus = "passed" | "failed"; +type Check = { + id: string; + status: CheckStatus; + detail: string; + elapsedMs: number; +}; +type RollbackState = { + drillId: string; + candidateDigest: string; + rollbackDigest: string; + activeImageDigests: string[]; + activeSlot: string; + desiredReplicas: number; + readyReplicas: number; + capturedAt: string; +}; +type AsyncEvidence = { + drillId: string; + outboxCorrelationId: string; + webhookEventId: string; + kafka: { status: "confirmed"; correlationId: string; duplicateDeliveries: number }; + temporal: { status: "confirmed"; workflowId: string; duplicateExecutions: number }; + payment: { status: "confirmed"; paymentReference: string; transactionId: string; duplicateTransitions: number }; +}; +type ObservabilityEvidence = { + drillId: string; + capturedAt: string; + observationWindowSeconds: number; + allWithinApprovedBaseline: boolean; + panics: number; + serializationErrors: number; + crashLoopRestarts: number; + grpcServerErrors: number; + consumerLag: number; +}; + +type PlaywrightResult = { status?: string; retry?: number }; +type PlaywrightTest = { results?: PlaywrightResult[] }; +type PlaywrightSpec = { tests?: PlaywrightTest[] }; +type PlaywrightSuite = { suites?: PlaywrightSuite[]; specs?: PlaywrightSpec[] }; +type PlaywrightReport = { suites?: PlaywrightSuite[] }; + +type Report = { + schemaVersion: 1; + kind: "staging-rollback-verification"; + sourceCommit: string; + drillId: string; + candidateDigest: string; + rollbackDigest: string; + startedAt: string; + completedAt: string; + passed: boolean; + checks: Check[]; +}; + +const SHA256 = /^sha256:[a-f0-9]{64}$/; +const DRILL_ID = /^staging-rollback-[a-z0-9-]{8,80}$/; + +function required(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function optional(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value || undefined; +} + +function ensureDigest(name: string, value: string): string { + if (!SHA256.test(value)) throw new Error(`${name} must be a sha256 immutable image digest`); + return value; +} + +function ensureStagingUrl(name: string, raw: string): URL { + const url = new URL(raw); + if (url.protocol !== "https:") throw new Error(`${name} must use HTTPS`); + if (!/staging/i.test(url.hostname)) throw new Error(`${name} must target a staging hostname`); + return url; +} + +function ensureStagingDatabaseUrl(name: string, raw: string, hostAllowlist: Set): URL { + const url = new URL(raw); + if (!/^postgres(ql)?:$/.test(url.protocol)) throw new Error(`${name} must be a PostgreSQL URL`); + if (!hostAllowlist.has(url.hostname.toLowerCase())) { + throw new Error(`${name} host ${url.hostname} is not listed in STAGING_READONLY_DB_HOSTS`); + } + return url; +} + +function parseJsonFile(file: string, label: string): Promise { + return fs.readFile(file, "utf8").then((text) => { + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`${label} is not valid JSON`); + } + }); +} + +function bounded(value: unknown, limit = 200): string { + return String(value ?? "").replace(/[\r\n]+/g, " ").slice(0, limit); +} + +async function fetchBounded(url: URL, init?: RequestInit): Promise<{ response: Response; text: string; elapsedMs: number }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + const started = performance.now(); + try { + const response = await fetch(url, { ...init, signal: controller.signal, headers: { Accept: "application/json", ...(init?.headers ?? {}) } }); + return { response, text: await response.text(), elapsedMs: Math.round(performance.now() - started) }; + } finally { + clearTimeout(timeout); + } +} + +function parseJson(text: string, label: string): Record | unknown[] { + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== "object") throw new Error("not an object"); + return parsed as Record | unknown[]; + } catch { + throw new Error(`${label} did not return valid JSON`); + } +} + +function collectPlaywrightResults(suites: PlaywrightSuite[] | undefined): PlaywrightResult[] { + const results: PlaywrightResult[] = []; + for (const suite of suites ?? []) { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) results.push(...(test.results ?? [])); + } + results.push(...collectPlaywrightResults(suite.suites)); + } + return results; +} + +function verifyReleaseSmokeReport(report: PlaywrightReport): string { + const results = collectPlaywrightResults(report.suites); + if (results.length !== 5) throw new Error(`release-smoke must report exactly five test results, received ${results.length}`); + const nonPassing = results.filter((result) => result.status !== "passed" || result.retry !== 0); + if (nonPassing.length > 0) throw new Error(`release-smoke has ${nonPassing.length} failed, skipped, or retried result(s)`); + return "five release-smoke browser/API checks passed with zero retries"; +} + +function hashAuditRow(row: { action: string; resource_type: string | null; resource_id: string | number | null; user_id: string | number | null; details: string; created_at: string }, previousHash: string): string { + const payload = [ + previousHash, + row.action, + row.resource_type ?? "", + row.resource_id == null ? "" : String(row.resource_id), + row.user_id == null ? "" : String(row.user_id), + row.details, + row.created_at, + ].join("|"); + return crypto.createHash("sha256").update(payload).digest("hex"); +} + +function check(id: string, fn: () => Promise): Promise { + const started = performance.now(); + return fn().then( + (detail) => ({ id, status: "passed", detail, elapsedMs: Math.round(performance.now() - started) }), + (error: unknown) => ({ id, status: "failed", detail: bounded(error instanceof Error ? error.message : error, 500), elapsedMs: Math.round(performance.now() - started) }) + ); +} + +async function queryOne>(client: PoolClient, sql: string, values: unknown[] = []): Promise { + const result = await client.query(sql, values); + if (result.rows.length !== 1) throw new Error(`expected exactly one database row, received ${result.rows.length}`); + return result.rows[0]; +} + +async function assertReadOnlyPrincipal(client: PoolClient, expectedUser: string): Promise { + const identity = await queryOne<{ current_user: string; transaction_read_only: string }>( + client, + "SELECT current_user, current_setting('transaction_read_only') AS transaction_read_only" + ); + if (identity.current_user !== expectedUser) throw new Error(`database current_user ${identity.current_user} does not match STAGING_READONLY_DATABASE_USER`); + if (identity.transaction_read_only !== "on") throw new Error("database transaction is not read-only"); + + const privilegeCheck = await queryOne>( + client, + `SELECT + has_table_privilege(current_user, 'public.domain_event_outbox', 'INSERT') AS outbox_insert, + has_table_privilege(current_user, 'public.payment_commands', 'INSERT') AS payment_insert, + has_table_privilege(current_user, 'public.webhook_deliveries', 'INSERT') AS webhook_insert, + has_table_privilege(current_user, 'public.audit_logs', 'INSERT') AS audit_insert` + ); + const writable = Object.entries(privilegeCheck).filter(([, allowed]) => allowed); + if (writable.length > 0) throw new Error(`read-only principal has INSERT privileges: ${writable.map(([name]) => name).join(', ')}`); + return `read-only principal ${identity.current_user} verified`; +} + +async function verifyAuditChain(client: PoolClient, startId: number): Promise { + const predecessor = await client.query<{ hash_chain: string | null }>( + `SELECT hash_chain + FROM public.audit_logs + WHERE id < $1 + ORDER BY id DESC + LIMIT 1`, + [startId] + ); + const predecessorHash = predecessor.rows[0]?.hash_chain; + if (predecessor.rows.length > 0 && !predecessorHash) { + throw new Error(`audit-chain predecessor before id ${startId} has no hash_chain`); + } + + const rows = await client.query<{ + id: number; + action: string; + resource_type: string | null; + resource_id: string | null; + user_id: string | null; + details: string; + created_at: string; + previous_hash: string | null; + hash_chain: string | null; + }>( + `SELECT id, action, resource_type, resource_id, user_id, + COALESCE(details::text, '{}') AS details, + created_at::text AS created_at, previous_hash, hash_chain + FROM public.audit_logs + WHERE id >= $1 + ORDER BY id ASC`, + [startId] + ); + if (rows.rows.length === 0) throw new Error(`no audit logs found at or after audit-chain start id ${startId}`); + + let previous = predecessorHash ?? "GENESIS"; + for (const row of rows.rows) { + if (!row.hash_chain) throw new Error(`audit_logs id ${row.id} is missing hash_chain; no backfill is permitted during verification`); + const expectedPrevious = previous === "GENESIS" ? null : previous; + if (row.previous_hash !== expectedPrevious) throw new Error(`audit_logs id ${row.id} has an invalid previous_hash link`); + const expected = hashAuditRow(row, previous); + if (row.hash_chain !== expected) throw new Error(`audit_logs id ${row.id} has a hash-chain mismatch`); + previous = row.hash_chain; + } + return `audit chain verified across ${rows.rows.length} rows starting at id ${startId}`; +} + +function requireExactState(value: RollbackState, drillId: string, candidateDigest: string, rollbackDigest: string): string { + if (value.drillId !== drillId) throw new Error("rollback-state drillId does not match STAGING_ROLLBACK_DRILL_ID"); + if (value.candidateDigest !== candidateDigest || value.rollbackDigest !== rollbackDigest) throw new Error("rollback-state image digests do not match requested digests"); + if (!Array.isArray(value.activeImageDigests) || value.activeImageDigests.length === 0) throw new Error("rollback-state has no active image digests"); + if (value.activeImageDigests.some((digest) => digest !== rollbackDigest)) throw new Error("active traffic is not exclusively serving the approved rollback digest"); + if (!value.activeSlot) throw new Error("rollback-state activeSlot is required"); + if (!Number.isInteger(value.desiredReplicas) || value.desiredReplicas < 1 || value.readyReplicas !== value.desiredReplicas) throw new Error("rollback-state replicas are not fully ready"); + if (Number.isNaN(Date.parse(value.capturedAt))) throw new Error("rollback-state capturedAt is invalid"); + return `active slot ${value.activeSlot} serves ${value.readyReplicas}/${value.desiredReplicas} replicas at rollback digest`; +} + +function requireAsyncEvidence(value: AsyncEvidence, drillId: string, paymentReference: string): string { + if (value.drillId !== drillId) throw new Error("async reconciliation drillId mismatch"); + if (!value.outboxCorrelationId.startsWith(`${drillId}:`)) throw new Error("outbox correlation ID is outside drill scope"); + if (!value.webhookEventId.startsWith(`${drillId}:`)) throw new Error("webhook event ID is outside drill scope"); + if (value.kafka.status !== "confirmed" || value.kafka.correlationId !== value.outboxCorrelationId || value.kafka.duplicateDeliveries !== 0) throw new Error("Kafka reconciliation is not exactly once for the drill correlation ID"); + if (value.temporal.status !== "confirmed" || !value.temporal.workflowId || value.temporal.duplicateExecutions !== 0) throw new Error("Temporal reconciliation is incomplete or duplicate"); + if (value.payment.status !== "confirmed" || value.payment.paymentReference !== paymentReference || !value.payment.transactionId || value.payment.duplicateTransitions !== 0) throw new Error("payment reconciliation is incomplete or duplicate"); + return `Kafka, Temporal, and payment reconciliation confirmed for ${value.outboxCorrelationId}`; +} + +function requireObservabilityEvidence(value: ObservabilityEvidence, drillId: string): string { + if (value.drillId !== drillId) throw new Error("observability evidence drillId mismatch"); + if (Number.isNaN(Date.parse(value.capturedAt))) throw new Error("observability evidence capturedAt is invalid"); + if (!Number.isInteger(value.observationWindowSeconds) || value.observationWindowSeconds < 300) throw new Error("observability window must be at least 300 seconds"); + if (!value.allWithinApprovedBaseline) throw new Error("observability evidence reports an approved-baseline breach"); + if (value.panics !== 0 || value.serializationErrors !== 0 || value.crashLoopRestarts !== 0 || value.grpcServerErrors !== 0 || value.consumerLag !== 0) { + throw new Error("observability evidence reports panic, serialization, restart, gRPC, or consumer-lag failure"); + } + return `five-minute observability window is within approved baseline`; +} + +async function main(): Promise { + const sourceCommit = required("STAGING_SOURCE_COMMIT"); + if (!/^[a-f0-9]{40}$/i.test(sourceCommit)) throw new Error("STAGING_SOURCE_COMMIT must be a full commit SHA"); + const drillId = required("STAGING_ROLLBACK_DRILL_ID"); + if (!DRILL_ID.test(drillId)) throw new Error("STAGING_ROLLBACK_DRILL_ID is invalid"); + const candidateDigest = ensureDigest("STAGING_CANDIDATE_IMAGE_DIGEST", required("STAGING_CANDIDATE_IMAGE_DIGEST")); + const rollbackDigest = ensureDigest("STAGING_ROLLBACK_IMAGE_DIGEST", required("STAGING_ROLLBACK_IMAGE_DIGEST")); + if (candidateDigest === rollbackDigest) throw new Error("candidate and rollback image digests must differ"); + const base = ensureStagingUrl("STAGING_TEST_BASE_URL", required("STAGING_TEST_BASE_URL")); + const readonlyUser = required("STAGING_READONLY_DATABASE_USER"); + const allowedHosts = new Set(required("STAGING_READONLY_DB_HOSTS").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)); + const readonlyUrl = ensureStagingDatabaseUrl("STAGING_READONLY_DATABASE_URL", required("STAGING_READONLY_DATABASE_URL"), allowedHosts); + if (readonlyUrl.username !== readonlyUser) throw new Error("STAGING_READONLY_DATABASE_URL user does not match STAGING_READONLY_DATABASE_USER"); + const stateFile = path.resolve(required("STAGING_ROLLBACK_STATE_FILE")); + const asyncEvidenceFile = path.resolve(required("STAGING_ASYNC_RECONCILIATION_FILE")); + const observabilityFile = path.resolve(required("STAGING_OBSERVABILITY_EVIDENCE_FILE")); + const releaseSmokeFile = path.resolve(required("STAGING_RELEASE_SMOKE_RESULT_FILE")); + const auditChainStartId = Number(required("STAGING_AUDIT_CHAIN_START_ID")); + if (!Number.isInteger(auditChainStartId) || auditChainStartId < 1) throw new Error("STAGING_AUDIT_CHAIN_START_ID must be a positive integer"); + const authPath = required("STAGING_AUTHORIZATION_PROBE_PATH"); + const authToken = required("STAGING_MFA_TOKEN"); + const authExpectedStatus = Number(required("STAGING_AUTHORIZATION_PROBE_EXPECTED_STATUS")); + if (!Number.isInteger(authExpectedStatus) || authExpectedStatus < 200 || authExpectedStatus > 299) throw new Error("STAGING_AUTHORIZATION_PROBE_EXPECTED_STATUS must be a 2xx status"); + if (!authPath.startsWith("/")) throw new Error("STAGING_AUTHORIZATION_PROBE_PATH must start with /"); + + const asyncEvidence = await parseJsonFile(asyncEvidenceFile, "STAGING_ASYNC_RECONCILIATION_FILE"); + const paymentReference = asyncEvidence.payment?.paymentReference; + const webhookEventId = asyncEvidence.webhookEventId; + if (!paymentReference || !webhookEventId) throw new Error("async reconciliation evidence must contain per-drill payment and webhook identities"); + + const startedAt = new Date().toISOString(); + const report: Report = { schemaVersion: 1, kind: "staging-rollback-verification", sourceCommit, drillId, candidateDigest, rollbackDigest, startedAt, completedAt: "", passed: false, checks: [] }; + const pool = new Pool({ connectionString: readonlyUrl.toString(), max: 1, ssl: { rejectUnauthorized: true } }); + + try { + const rollbackState = await parseJsonFile(stateFile, "STAGING_ROLLBACK_STATE_FILE"); + report.checks.push(await check("RB-01-rollback-artifact-and-traffic-state", async () => requireExactState(rollbackState, drillId, candidateDigest, rollbackDigest))); + + report.checks.push(await check("RB-03-api-health", async () => { + const result = await fetchBounded(new URL("/api/health", base)); + if (result.response.status !== 200) throw new Error(`expected HTTP 200, received ${result.response.status}`); + const body = parseJson(result.text, "health") as Record; + if (body.status !== "ok" || body.service !== "ndsep-api" || (body.checks as Record | undefined)?.database !== "ok") throw new Error("health response is not an API/database-ready result"); + return `healthy API/database response in ${result.elapsedMs}ms`; + })); + + report.checks.push(await check("RB-02-readiness", async () => { + const result = await fetchBounded(new URL("/api/ready", base)); + if (result.response.status !== 200) throw new Error(`expected readiness HTTP 200, received ${result.response.status}`); + return `readiness response in ${result.elapsedMs}ms`; + })); + + const releaseSmoke = await parseJsonFile(releaseSmokeFile, "STAGING_RELEASE_SMOKE_RESULT_FILE"); + report.checks.push(await check("RB-04-release-smoke-browser", async () => verifyReleaseSmokeReport(releaseSmoke))); + + report.checks.push(await check("RB-05-unauthenticated-boundary", async () => { + const auth = await fetchBounded(new URL("/api/trpc/auth.me?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%7D%7D", base)); + if (auth.response.status !== 200) throw new Error(`auth.me expected HTTP 200, received ${auth.response.status}`); + parseJson(auth.text, "auth.me"); + const worker = await fetchBounded(new URL("/api/workers/status", base)); + if (worker.response.status !== 401) throw new Error(`worker status expected anonymous 401, received ${worker.response.status}`); + const orchestration = await fetchBounded(new URL("/api/trpc/orchestration.temporalConfig?input=%7B%22json%22%3Anull%7D", base)); + if (orchestration.response.status !== 401) throw new Error(`temporalConfig expected anonymous 401, received ${orchestration.response.status}`); + return "well-formed anonymous auth response and protected metadata boundary verified"; + })); + + report.checks.push(await check("RB-06-security-headers", async () => { + const result = await fetchBounded(new URL("/api/health", base)); + if (result.response.headers.get("x-content-type-options") !== "nosniff") throw new Error("missing X-Content-Type-Options: nosniff"); + if (!result.response.headers.get("x-frame-options")) throw new Error("missing X-Frame-Options"); + if (result.response.headers.get("x-ndsep-api-version") !== "2.0.0") throw new Error("unexpected or missing X-NDSEP-API-Version"); + return "edge security headers verified"; + })); + + const client = await pool.connect(); + try { + await client.query("BEGIN READ ONLY"); + report.checks.push(await check("RB-11-readonly-database-principal", () => assertReadOnlyPrincipal(client, readonlyUser))); + + report.checks.push(await check("RB-07-outbox-identity", async () => { + const duplicates = await client.query<{ correlation_id: string; occurrences: string }>( + `SELECT correlation_id, COUNT(*)::text AS occurrences + FROM public.domain_event_outbox + GROUP BY correlation_id + HAVING COUNT(*) <> 1 + LIMIT 1` + ); + if (duplicates.rows.length) throw new Error(`duplicate outbox correlation_id ${duplicates.rows[0].correlation_id}`); + return "domain_event_outbox correlation IDs are unique"; + })); + + report.checks.push(await check("RB-08-outbox-leases", async () => { + const expired = await client.query<{ id: string }>( + `SELECT id::text + FROM public.domain_event_outbox + WHERE status = 'publishing' AND lease_expires_at < NOW() + LIMIT 1` + ); + if (expired.rows.length) throw new Error(`expired publishing outbox lease ${expired.rows[0].id}`); + return "no expired publishing outbox lease"; + })); + + report.checks.push(await check("RB-09-payment-command-identity", async () => { + const duplicates = await client.query<{ payment_reference: string }>( + `SELECT payment_reference + FROM public.payment_commands + GROUP BY payment_reference + HAVING COUNT(*) <> 1 + LIMIT 1` + ); + if (duplicates.rows.length) throw new Error(`duplicate payment reference ${duplicates.rows[0].payment_reference}`); + const drill = await queryOne<{ count: string }>(client, "SELECT COUNT(*)::text AS count FROM public.payment_commands WHERE payment_reference = $1", [paymentReference]); + if (drill.count !== "1") throw new Error(`drill payment reference has ${drill.count} command records`); + return "payment command identities are unique and drill command is singular"; + })); + + report.checks.push(await check("RB-10-payment-command-leases", async () => { + const expired = await client.query<{ id: string }>( + `SELECT id::text + FROM public.payment_commands + WHERE status IN ('processing_ledger', 'processing_settlement') + AND lease_expires_at < NOW() + LIMIT 1` + ); + if (expired.rows.length) throw new Error(`expired payment command lease ${expired.rows[0].id}`); + return "no expired payment processing lease"; + })); + + report.checks.push(await check("RB-14-webhook-ledger", async () => { + const deliveries = await client.query<{ attempts: string; success_count: string }>( + `SELECT COUNT(*)::text AS attempts, + COUNT(*) FILTER (WHERE success)::text AS success_count + FROM public.webhook_deliveries + WHERE payload ->> 'id' = $1`, + [webhookEventId] + ); + const row = deliveries.rows[0]; + if (!row || Number(row.attempts) < 1 || Number(row.success_count) < 1) throw new Error("drill webhook event has no successful durable delivery record"); + return `durable webhook ledger records ${row.attempts} attempt(s) with ${row.success_count} success(es)`; + })); + + report.checks.push(await check("RB-15-financial-ledger", async () => { + const command = await queryOne<{ status: string; tigerbeetle_transaction_id: string | null; mojaloop_reference: string | null }>( + client, + `SELECT status::text, tigerbeetle_transaction_id, mojaloop_reference + FROM public.payment_commands + WHERE payment_reference = $1`, + [paymentReference] + ); + if (command.status !== "completed") throw new Error(`drill payment command status is ${command.status}, not completed`); + if (!command.tigerbeetle_transaction_id || !command.mojaloop_reference) throw new Error("completed drill payment command lacks TigerBeetle or Mojaloop reference"); + return "completed payment command has both required external reconciliation references"; + })); + + report.checks.push(await check("RB-12-audit-hash-chain", () => verifyAuditChain(client, auditChainStartId))); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + + report.checks.push(await check("RB-16-authorized-control", async () => { + const result = await fetchBounded(new URL(authPath, base), { headers: { Authorization: `Bearer ${authToken}` } }); + if (result.response.status !== authExpectedStatus) throw new Error(`authorized staging control expected HTTP ${authExpectedStatus}, received ${result.response.status}`); + return `dedicated MFA staging control returned HTTP ${authExpectedStatus}`; + })); + + report.checks.push(await check("RB-13-async-reconciliation", async () => requireAsyncEvidence(asyncEvidence, drillId, paymentReference))); + + const observability = await parseJsonFile(observabilityFile, "STAGING_OBSERVABILITY_EVIDENCE_FILE"); + report.checks.push(await check("RB-17-observability", async () => requireObservabilityEvidence(observability, drillId))); + } finally { + await pool.end(); + report.completedAt = new Date().toISOString(); + report.passed = report.checks.length === 17 && report.checks.every((entry) => entry.status === "passed"); + const outDir = path.resolve(optional("STAGING_ROLLBACK_EVIDENCE_DIR") ?? "staging-rollback-evidence"); + await fs.mkdir(outDir, { recursive: true }); + const reportPath = path.join(outDir, "rollback-verification.json"); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + process.stdout.write(`Wrote sanitized rollback verification report: ${reportPath}\n`); + for (const entry of report.checks) process.stdout.write(`${entry.status === "passed" ? "PASS" : "FAIL"} ${entry.id}: ${entry.detail}\n`); + if (!report.passed) process.exitCode = 1; + } +} + +main().catch((error: unknown) => { + console.error(`STAGING ROLLBACK VERIFICATION FAILED: ${bounded(error instanceof Error ? error.message : error, 500)}`); + process.exitCode = 1; +}); diff --git a/scripts/seed-banking-compliance.mjs b/scripts/seed-banking-compliance.mjs index 66b19de9..5de3cea5 100644 --- a/scripts/seed-banking-compliance.mjs +++ b/scripts/seed-banking-compliance.mjs @@ -5,15 +5,15 @@ * nip_transactions, compliance_policies, breach_incidents, * consent_records, data_catalog_entries, enforcement_actions * - * Run: node scripts/seed-banking-compliance.mjs + * Synthetic non-production usage only: + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic node scripts/seed-banking-compliance.mjs */ import pg from "pg"; +import { getSyntheticSeedPoolOptions } from "./lib/synthetic-seed-safety.mjs"; const { Pool } = pg; -const DB_URL = process.env.LOCAL_DATABASE_URL ?? - (process.env.DATABASE_URL || "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db"); - -const pool = new Pool({ connectionString: DB_URL, ssl: false }); +const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); async function q(sql, params = []) { const client = await pool.connect(); diff --git a/scripts/seed-comprehensive.mjs b/scripts/seed-comprehensive.mjs index dbee2479..cbd86275 100644 --- a/scripts/seed-comprehensive.mjs +++ b/scripts/seed-comprehensive.mjs @@ -1,49 +1,49 @@ #!/usr/bin/env node /** * NDSEP Comprehensive Seed Script - * Creates all tables and seeds the entire platform with realistic Nigerian data. + * Seeds the maintained synthetic scenario set, then verifies every deployed public table has data. * - * Usage: - * node scripts/seed-comprehensive.mjs # Skip if data exists - * FORCE_SEED=1 node scripts/seed-comprehensive.mjs # Re-seed everything - * npm run seed:all # Via npm script - * npm run seed:all:force # Force re-seed + * Usage (synthetic, non-production databases only): + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic node scripts/seed-comprehensive.mjs + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic FORCE_SEED=1 node scripts/seed-comprehensive.mjs + * + * Non-loopback database targets additionally require the same explicit + * SYNTHETIC_SEED_ALLOW_REMOTE confirmation. Production-labelled database + * names and NODE_ENV=production are always refused. * * This script: * 1. Runs the core seed (organizations, assets, compliance, etc.) * 2. Creates banking tables if they don't exist * 3. Seeds banking module (institutions, KYC, AML, watchlist, payments, etc.) - * 4. Seeds any empty tables across the platform - * 5. Verifies all tables have data + * 4. Performs an authoritative public-table coverage check + * 5. Exits nonzero and lists every remaining empty table * * Environment variables: - * DATABASE_URL or POSTGRES_URL — PostgreSQL connection string - * FORCE_SEED=1 — Force re-seed even if data exists + * DATABASE_URL or POSTGRES_URL — explicitly supplied PostgreSQL connection string + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY — required for every run + * SYNTHETIC_SEED_ALLOW_REMOTE=NDSEP_SYNTHETIC_DATA_ONLY — additionally required for non-loopback targets + * FORCE_SEED=1 — permits re-seeding an explicitly confirmed synthetic target */ import pg from "pg"; import { readFileSync, existsSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import { + getSyntheticSeedPoolOptions, + inspectPublicTableCoverage, + requireCompleteSyntheticSeed, + summarizePublicTableCoverage, +} from "./lib/synthetic-seed-safety.mjs"; const __dir = dirname(fileURLToPath(import.meta.url)); -const envPath = join(__dir, "..", ".env"); -if (existsSync(envPath)) { - const lines = readFileSync(envPath, "utf8").split("\n"); - for (const line of lines) { - const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); - if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); - } -} const { Pool } = pg; -const DB_URL = - process.env.POSTGRES_URL || - process.env.DATABASE_URL || - "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db"; const FORCE = process.env.FORCE_SEED === "1"; -const pool = new Pool({ connectionString: DB_URL, ssl: false }); async function seedAll() { + const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); const client = await pool.connect(); try { console.log("=== NDSEP Comprehensive Seed Script ===\n"); @@ -110,43 +110,22 @@ async function seedAll() { } // ===================================================================== - // STEP 2: Verify all tables have data + // STEP 2: Verify all currently deployed public tables have data. + // A successful process result is forbidden if any table remains empty. // ===================================================================== - console.log("Step 2: Verifying all tables..."); - const { rows: tables } = await client.query( - "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" - ); - - let totalTables = 0; - let seededTables = 0; - let emptyTables = []; - let totalRows = 0; - - for (const { tablename } of tables) { - totalTables++; - const { rows } = await client.query(`SELECT COUNT(*) as c FROM "${tablename}"`); - const count = parseInt(rows[0].c); - totalRows += count; - if (count > 0) { - seededTables++; - } else { - emptyTables.push(tablename); - } - } - - console.log(`\n=== Seed Verification ===`); - console.log(`Total tables: ${totalTables}`); - console.log(`Seeded tables: ${seededTables}`); - console.log(`Total rows: ${totalRows}`); - - if (emptyTables.length > 0) { - console.log(`\nEmpty tables (${emptyTables.length}):`); - for (const t of emptyTables) { - console.log(` - ${t}`); - } - } else { - console.log(`\nAll ${totalTables} tables have data!`); + console.log("Step 2: Verifying all public tables..."); + const coverage = await inspectPublicTableCoverage(client); + const coverageSummary = summarizePublicTableCoverage(coverage); + console.log("\n=== Seed Verification ==="); + console.log(`Total tables: ${coverageSummary.totalTables}`); + console.log(`Populated tables: ${coverageSummary.populatedTables}`); + console.log(`Total rows: ${coverageSummary.totalRows}`); + if (coverageSummary.emptyTables.length > 0) { + console.log(`\nEmpty tables (${coverageSummary.emptyTables.length}):`); + for (const table of coverageSummary.emptyTables) console.log(` - ${table}`); } + requireCompleteSyntheticSeed(coverageSummary); + console.log(`\nAll ${coverageSummary.totalTables} public tables have synthetic data.`); // ===================================================================== // STEP 3: Print banking stats diff --git a/scripts/seed-ndpa-compliance.mjs b/scripts/seed-ndpa-compliance.mjs index 02eb1f82..2f1efec4 100644 --- a/scripts/seed-ndpa-compliance.mjs +++ b/scripts/seed-ndpa-compliance.mjs @@ -1,15 +1,13 @@ /** * NDPA Compliance Tables Seed Script * Seeds all 18 new NDPA compliance tables with realistic Nigerian demo data. - * Uses the DATABASE_URL env var (PostgreSQL via Neon/Supabase). + * Synthetic non-production usage only. It requires DATABASE_URL or POSTGRES_URL + * and SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY. */ import pg from "pg"; -import dotenv from "dotenv"; -dotenv.config(); - +import { getSyntheticSeedPoolOptions } from "./lib/synthetic-seed-safety.mjs"; const { Pool } = pg; -const DB_URL = process.env.POSTGRES_URL || (process.env.DATABASE_URL || "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db"); -const pool = new Pool({ connectionString: DB_URL, ssl: false }); +const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); async function query(sql, params = []) { const client = await pool.connect(); diff --git a/scripts/seed-org-users.mjs b/scripts/seed-org-users.mjs index 1fbef754..d9c64333 100644 --- a/scripts/seed-org-users.mjs +++ b/scripts/seed-org-users.mjs @@ -1,19 +1,13 @@ /** * Seed organization_users table - * Links existing users to existing organizations for demo data. + * Links existing users to existing organizations for synthetic demo data. + * Requires SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY and an explicitly + * named non-production DATABASE_URL or POSTGRES_URL. */ import pg from "pg"; -import * as dotenv from "dotenv"; - -dotenv.config(); - +import { getSyntheticSeedPoolOptions } from "./lib/synthetic-seed-safety.mjs"; const { Pool } = pg; -const PG_URL = process.env.LOCAL_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgresql://ndsep_user:changeme@localhost:5432/ndsep_db"; - -const pool = new Pool({ - connectionString: PG_URL, - ssl: PG_URL.includes("sslmode=require") ? { rejectUnauthorized: false } : false, -}); +const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); async function seed() { const client = await pool.connect(); diff --git a/scripts/seed-phase3.mjs b/scripts/seed-phase3.mjs index b5b2c24f..4ab3a2ab 100644 --- a/scripts/seed-phase3.mjs +++ b/scripts/seed-phase3.mjs @@ -1,8 +1,11 @@ +/** + * Synthetic non-production fixture seed. Requires an explicit named synthetic, + * demo, test, sandbox, or staging PostgreSQL database target and confirmation. + */ import pg from "pg"; +import { getSyntheticSeedPoolOptions } from "./lib/synthetic-seed-safety.mjs"; const { Pool } = pg; -const pool = new Pool({ - connectionString: (process.env.DATABASE_URL || "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db"), -}); +const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); async function seed() { const client = await pool.connect(); @@ -352,4 +355,7 @@ async function seed() { } } -seed().catch(console.error); +seed().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/seed-remaining-synthetic-fixtures.mjs b/scripts/seed-remaining-synthetic-fixtures.mjs new file mode 100644 index 00000000..ffea71f5 --- /dev/null +++ b/scripts/seed-remaining-synthetic-fixtures.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Apply the schema-aware synthetic fixture extension after the main synthetic + * seed path. This command is guarded by synthetic-seed-safety and is never a + * production, release-evidence, or compliance-evidence loader. + * + * Usage: + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic \ + * node scripts/seed-remaining-synthetic-fixtures.mjs + */ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import pg from "pg"; +import { + getSyntheticSeedPoolOptions, + inspectPublicTableCoverage, + summarizePublicTableCoverage, +} from "./lib/synthetic-seed-safety.mjs"; + +export const REMAINING_FIXTURE_TABLES = Object.freeze([ + "analytics_snapshots", + "article40_codes", + "dpco_audit_service_records", + "dpco_registry_service_records", + "dpco_verification_service_records", + "dt_monte_carlo_stats", + "dt_org_agents", + "dt_policy_impacts", + "dt_sandboxes", + "dt_simulation_results", + "dt_simulations", + "marketplace_plugins", + "mobile_push_devices", + "noc_agent_memory", + "onboarding_checklists", +]); + +export function requireFixtureTablesPopulated(coverageSummary) { + if (!coverageSummary || !Array.isArray(coverageSummary.emptyTables)) { + throw new Error("Synthetic fixture coverage summary is invalid"); + } + const missing = REMAINING_FIXTURE_TABLES.filter(table => coverageSummary.emptyTables.includes(table)); + if (missing.length > 0) { + throw new Error(`Synthetic fixture extension did not populate required table(s): ${missing.join(", ")}`); + } + return coverageSummary; +} + +async function main() { + const sqlPath = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "seed_remaining_synthetic_fixtures.sql"); + const sql = await readFile(sqlPath, "utf8"); + if (!sql.trim()) throw new Error("Synthetic fixture extension SQL is empty"); + + const pool = new pg.Pool(getSyntheticSeedPoolOptions(process.env)); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(sql); + await client.query("COMMIT"); + + const coverageSummary = summarizePublicTableCoverage(await inspectPublicTableCoverage(client)); + requireFixtureTablesPopulated(coverageSummary); + console.log(JSON.stringify({ + status: "passed", + scope: "remaining-synthetic-fixture-extension-only", + populatedTables: REMAINING_FIXTURE_TABLES, + allPublicTablesPopulated: coverageSummary.complete, + emptyTables: coverageSummary.emptyTables, + noReadinessCredit: true, + }, null, 2)); + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // The original database error remains the actionable failure. + } + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + main().catch((error) => { + console.error(`Remaining synthetic fixture extension failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/seed-sector-entities.mjs b/scripts/seed-sector-entities.mjs index bfe2dd45..b88e4bef 100644 --- a/scripts/seed-sector-entities.mjs +++ b/scripts/seed-sector-entities.mjs @@ -1,13 +1,13 @@ /** * Seed Sector Entities - NDSEP Platform * Seeds: fintech_companies, energy_companies, insurance_companies, telecom_operators - * Usage: node scripts/seed-sector-entities.mjs + * Synthetic non-production usage only. Requires DATABASE_URL or POSTGRES_URL and + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY. */ import pg from "pg"; -import { config } from "dotenv"; -config(); +import { getSyntheticSeedPoolOptions } from "./lib/synthetic-seed-safety.mjs"; const { Pool } = pg; -const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); async function q(sql, params = []) { const client = await pool.connect(); try { const res = await client.query(sql, params); return res.rows; } finally { client.release(); } diff --git a/scripts/seed.mjs b/scripts/seed.mjs index e28aa320..962784c0 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -1,33 +1,23 @@ /** * NDSEP Database Seed Script — Nigerian Demo Data * Uses correct PostgreSQL schema column names from drizzle/schema.ts - * Run: pnpm db:seed (skips if data exists) - * Run: pnpm db:seed:force (always re-seeds) + * Synthetic non-production usage only: + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic pnpm db:seed + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic FORCE_SEED=1 pnpm db:seed + * + * Production-labelled targets and NODE_ENV=production are refused. */ import pg from "pg"; -import { readFileSync, existsSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; +import { getSyntheticSeedPoolOptions } from "./lib/synthetic-seed-safety.mjs"; -const __dir = dirname(fileURLToPath(import.meta.url)); -const envPath = join(__dir, "..", ".env"); -if (existsSync(envPath)) { - const lines = readFileSync(envPath, "utf8").split("\n"); - for (const line of lines) { - const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); - if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); - } -} const { Pool } = pg; -// Use local PostgreSQL (not TiDB) — the app uses drizzle-orm/node-postgres -const DB_URL = - process.env.POSTGRES_URL || - (process.env.DATABASE_URL || "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db"); const FORCE = process.env.FORCE_SEED === "1"; -const pool = new Pool({ connectionString: DB_URL, ssl: false }); async function seed() { + const pool = new Pool(getSyntheticSeedPoolOptions(process.env)); const client = await pool.connect(); try { console.log("🌱 Seeding NDSEP database with Nigerian demo data..."); @@ -322,4 +312,7 @@ async function seed() { } } -seed().catch(console.error); +seed().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/verify-synthetic-fixture-scenario.mjs b/scripts/verify-synthetic-fixture-scenario.mjs new file mode 100644 index 00000000..a010c146 --- /dev/null +++ b/scripts/verify-synthetic-fixture-scenario.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Verifies a synthetic demonstration scenario against the source-controlled + * uncovered-table manifest. This is not a production readiness, compliance, + * release, residency, or all-public-tables-populated verifier. + * + * Usage: + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic \ + * pnpm seed:verify:scenario -- --allow-runtime-exemptions + */ +import pg from "pg"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + getSyntheticSeedPoolOptions, + inspectPublicTableCoverage, + summarizePublicTableCoverage, +} from "./lib/synthetic-seed-safety.mjs"; +import { + evaluateSyntheticFixtureCoverage, + loadSyntheticFixturePlan, + requireSyntheticFixtureScenario, +} from "./lib/synthetic-fixture-plan.mjs"; + +const expectedArgument = "--allow-runtime-exemptions"; +if (process.argv.length !== 3 || process.argv[2] !== expectedArgument) { + throw new Error(`Scenario verification requires the exact argument ${expectedArgument}`); +} + +const pool = new pg.Pool(getSyntheticSeedPoolOptions(process.env)); +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const manifestPath = join(scriptDirectory, "fixtures", "uncovered-table-scenario-manifest.json"); + +async function main() { + const client = await pool.connect(); + try { + const coverageSummary = summarizePublicTableCoverage(await inspectPublicTableCoverage(client)); + const manifest = await loadSyntheticFixturePlan(manifestPath); + const scenarioReport = evaluateSyntheticFixtureCoverage(coverageSummary, manifest); + console.log(JSON.stringify({ coverageSummary, scenarioReport }, null, 2)); + requireSyntheticFixtureScenario(scenarioReport); + if (!coverageSummary.complete) { + console.warn("Synthetic scenario is complete under expiring runtime-owned exemptions; all public tables are not populated and no readiness credit is granted."); + } + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((error) => { + console.error(`Synthetic fixture scenario verification failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/scripts/verify-synthetic-seed-coverage.mjs b/scripts/verify-synthetic-seed-coverage.mjs new file mode 100644 index 00000000..091ff724 --- /dev/null +++ b/scripts/verify-synthetic-seed-coverage.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * Read-only verifier for the synthetic seed environment. + * + * Usage (synthetic, non-production database only): + * SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY \ + * DATABASE_URL=postgresql://.../ndsep_synthetic pnpm seed:verify + * + * The command never writes data. It reports the live public table count and + * exits nonzero if even one table has zero rows. + */ +import pg from "pg"; +import { + getSyntheticSeedPoolOptions, + inspectPublicTableCoverage, + requireCompleteSyntheticSeed, + summarizePublicTableCoverage, +} from "./lib/synthetic-seed-safety.mjs"; + +const pool = new pg.Pool(getSyntheticSeedPoolOptions(process.env)); + +async function main() { + const client = await pool.connect(); + try { + const coverage = await inspectPublicTableCoverage(client); + const summary = summarizePublicTableCoverage(coverage); + console.log(JSON.stringify(summary, null, 2)); + requireCompleteSyntheticSeed(summary); + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((error) => { + console.error(`Synthetic seed coverage verification failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/seed_demo.mjs b/seed_demo.mjs index 240a6790..d78dd0ea 100644 --- a/seed_demo.mjs +++ b/seed_demo.mjs @@ -1,14 +1,11 @@ +/** + * Synthetic demo fixture only. Requires an explicitly named non-production + * PostgreSQL target and SYNTHETIC_SEED_CONFIRMATION=NDSEP_SYNTHETIC_DATA_ONLY. + */ import pg from 'pg'; -import { readFile } from 'fs/promises'; +import { getSyntheticSeedPoolOptions } from './scripts/lib/synthetic-seed-safety.mjs'; -if (!process.env.DATABASE_URL) { - console.error("DATABASE_URL environment variable is required. Set it before running this script."); - process.exit(1); -} -const pool = new pg.Pool({ - connectionString: process.env.DATABASE_URL, - ssl: false -}); +const pool = new pg.Pool(getSyntheticSeedPoolOptions(process.env)); const client = await pool.connect(); try { @@ -82,6 +79,7 @@ try { await client.query('ROLLBACK'); console.error('Seed error:', e.message); console.error(e.stack); + process.exitCode = 1; } finally { client.release(); await pool.end(); diff --git a/server/_core/env.ts b/server/_core/env.ts index e0857c6e..638ce7d7 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -71,7 +71,7 @@ export const ENV = { keycloakRealm: process.env.KEYCLOAK_REALM ?? "ndsep", keycloakClientId: process.env.KEYCLOAK_CLIENT_ID ?? "ndsep-platform", keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "", - keycloakEnabled: process.env.KEYCLOAK_ENABLED !== "false", + keycloakEnabled: process.env.KEYCLOAK_ENABLED === "true", // ── Permify Authorization ───────────────────────────────────────────────── // Set PERMIFY_URL to your Permify server (e.g., http://permify:3476). @@ -111,9 +111,9 @@ export const ENV = { lakehouseCatalogUrl: process.env.LAKEHOUSE_CATALOG_URL ?? "http://localhost:8181", lakehouseS3Endpoint: process.env.LAKEHOUSE_S3_ENDPOINT ?? "http://localhost:9000", lakehouseS3Bucket: process.env.LAKEHOUSE_S3_BUCKET ?? "ndsep-lakehouse", - lakehouseS3AccessKey: process.env.LAKEHOUSE_S3_ACCESS_KEY ?? "minioadmin", - lakehouseS3SecretKey: process.env.LAKEHOUSE_S3_SECRET_KEY ?? "minioadmin", - lakehouseEnabled: process.env.LAKEHOUSE_ENABLED !== "false", + lakehouseS3AccessKey: process.env.LAKEHOUSE_S3_ACCESS_KEY ?? "", + lakehouseS3SecretKey: process.env.LAKEHOUSE_S3_SECRET_KEY ?? "", + lakehouseEnabled: process.env.LAKEHOUSE_ENABLED === "true", // ── Orchestration Service URLs ──────────────────────────────────────────── // These are the internal ports for the orchestration Go/Python services. @@ -128,10 +128,10 @@ export const ENV = { orchestrationMlPipelineUrl: process.env.ORCHESTRATION_ML_PIPELINE_URL ?? "http://localhost:8125", // ── Termii SMS Enforcement Alerts ───────────────────────────────────────── - termiiApiKey: process.env.TERMII_API_KEY ?? "TLtest_default_key_ndsep_2026", + termiiApiKey: process.env.TERMII_API_KEY ?? "", termiiBaseUrl: process.env.TERMII_BASE_URL ?? "https://api.ng.termii.com", termiiSenderId: process.env.TERMII_SENDER_ID ?? "NDSEP", - termiiEnabled: process.env.TERMII_ENABLED !== "false", + termiiEnabled: process.env.TERMII_ENABLED === "true", ndpcPhoneNumber: process.env.NDPC_PHONE_NUMBER ?? "+2348012345678", ndpcEmail: process.env.NDPC_EMAIL ?? "enforcement@ndpc.gov.ng", slaAlertEmail: process.env.SLA_ALERT_EMAIL ?? "sla-alerts@ndsep.nitda.gov.ng", @@ -152,12 +152,12 @@ export const ENV = { vaultMaxFileSizeMb: parseInt(process.env.VAULT_MAX_FILE_SIZE_MB ?? "50", 10), // ── API Key Management ──────────────────────────────────────────────────── - apiKeySalt: process.env.API_KEY_SALT ?? "ndsep_api_key_salt_2026_production_default", + apiKeySalt: process.env.API_KEY_SALT ?? "", apiKeyPrefix: process.env.API_KEY_PREFIX ?? "ndsep_live_", apiKeyTestPrefix: process.env.API_KEY_TEST_PREFIX ?? "ndsep_test_", // ── Webhook Delivery ────────────────────────────────────────────────────── - webhookSigningSecret: process.env.WEBHOOK_SIGNING_SECRET ?? "ndsep_webhook_signing_secret_2026_default", + webhookSigningSecret: process.env.WEBHOOK_SIGNING_SECRET ?? "", webhookTimeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS ?? "10000", 10), webhookMaxRetries: parseInt(process.env.WEBHOOK_MAX_RETRIES ?? "3", 10), diff --git a/server/_core/index.ts b/server/_core/index.ts index 97df7303..a2fc3428 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -22,7 +22,6 @@ import helmet from "helmet"; import compression from "compression"; import { rateLimit, ipKeyGenerator } from "express-rate-limit"; import Redis from "ioredis"; -import RedisStore, { type RedisReply } from "rate-limit-redis"; import { randomBytes } from "node:crypto"; import pinoHttp from "pino-http"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; @@ -66,12 +65,13 @@ import { verifyMigrations } from "../migrationVerifier"; import { applyDatabaseMigrations } from "../dbMigrations"; import { regenerateSession, generateSessionNonce } from "../sessionSecurity"; import { traceMiddleware } from "../telemetry"; -import { initWebhookSystem, deliverWebhookEvent } from "../webhookSystem"; +import { initWebhookSystem } from "../webhookSystem"; import { createVersionedEndpoints } from "../apiVersioning"; import { registerMobileApi } from "../mobileApi"; import { registerMojaloopCallbacks } from "../mojaloopCallback"; import { sdk } from "./sdk"; import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; +import { validateWorkerEventShape, verifyWorkerEventSignature, workerEventNonceKey, WORKER_EVENT_MAX_AGE_MS } from "../workerEventAuth"; import { getSessionCookieOptions } from "./cookies"; // ─── Startup time for uptime calculation ───────────────────────────────────── @@ -81,6 +81,9 @@ const STARTUP_TIME = Date.now(); import { captureError } from "../errorMonitoring"; import { startHealthMonitor, stopHealthMonitor } from "../middlewareConnector"; import { startKafkaConsumer, stopKafkaConsumer } from "../kafkaConsumer"; +import { startDurableOutbox, stopDurableOutbox } from "../eventBus"; +import { startPaymentCommandProcessor, stopPaymentCommandProcessor } from "../paymentCommandProcessor"; +import { redisRateLimitStore } from "../redisRateLimitStore"; import { wafEnforcementMiddleware } from "../wafMiddleware"; process.on("uncaughtException", (err) => { @@ -114,31 +117,17 @@ async function findAvailablePort(startPort: number = 3000): Promise { const safeIpKey = (req: import("express").Request) => ipKeyGenerator(req.ip ?? req.socket.remoteAddress ?? "unknown"); // Redis-backed rate limit stores survive restarts and work across cluster instances. -// Each limiter receives an independent store and prefix; express-rate-limit rejects -// reuse of one store instance across multiple limiter configurations. -let createRateLimitStore: ((prefix: string) => RedisStore) | undefined; +// Only explicit test execution uses express-rate-limit's isolated memory store. +// The shared factory throws during non-test startup when REDIS_URL is absent or +// production TLS trust requirements are not satisfied; store errors fail closed. +let createRateLimitStore: ((prefix: string) => ReturnType) | undefined; if (process.env.NODE_ENV === "test") { logger.info("[RateLimit] Test environment uses isolated in-memory stores"); } else { - try { - const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379"; - const redisClient = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 1, enableOfflineQueue: false }); - redisClient.on("error", (err) => { - logger.warn({ err }, "[RateLimit] Redis client error"); - }); - redisClient.connect().then(() => { - logger.info("[RateLimit] Redis-backed stores active"); - }).catch(() => { - logger.warn("[RateLimit] Redis unavailable — rate limit stores may fall back to their configured error policy"); - }); - createRateLimitStore = (prefix: string) => new RedisStore({ - prefix, - sendCommand: (command: string, ...args: string[]) => - redisClient.call(command, ...args) as Promise, - }); - } catch { - logger.warn("[RateLimit] rate-limit-redis not available — using in-memory stores"); - } + const configuredStore = redisRateLimitStore("ndsep:startup-check:"); + if (!configuredStore) throw new Error("Redis rate-limit store is required outside test execution"); + createRateLimitStore = (prefix: string) => redisRateLimitStore(prefix); + logger.info("[RateLimit] Redis-backed stores configured; store failures reject requests"); } /** General API rate limit: configurable per IP */ @@ -150,7 +139,7 @@ const apiLimiter = rateLimit({ message: { error: "Too many requests — please slow down." }, keyGenerator: safeIpKey, skip: (req) => process.env.NODE_ENV === "development" && req.ip === "::1", - ...(createRateLimitStore ? { store: createRateLimitStore("ndsep:api:") } : {}), + ...(createRateLimitStore ? { store: createRateLimitStore("ndsep:api:"), passOnStoreError: false } : {}), }); /** Strict auth rate limit: configurable per IP (brute-force protection) */ const authLimiter = rateLimit({ @@ -160,16 +149,48 @@ const authLimiter = rateLimit({ legacyHeaders: false, message: { error: "Too many authentication attempts — please try again later." }, keyGenerator: safeIpKey, - ...(createRateLimitStore ? { store: createRateLimitStore("ndsep:auth:") } : {}), + ...(createRateLimitStore ? { store: createRateLimitStore("ndsep:auth:"), passOnStoreError: false } : {}), }); /** Worker event relay: configurable per minute (internal workers) */ +const workerNonceMemory = new Map(); +let workerNonceRedis: Redis | undefined; + +async function claimWorkerEventNonce(workerId: string, nonce: string): Promise { + const key = workerEventNonceKey(workerId, nonce); + const now = Date.now(); + const ttlMs = WORKER_EVENT_MAX_AGE_MS; + if (process.env.NODE_ENV === "production") { + try { + if (!workerNonceRedis) { + workerNonceRedis = new Redis(process.env.REDIS_URL!, { + lazyConnect: true, + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + tls: process.env.REDIS_URL?.startsWith("rediss:") ? {} : undefined, + }); + } + if (workerNonceRedis.status === "wait") await workerNonceRedis.connect(); + return await workerNonceRedis.set(key, "1", "PX", ttlMs, "NX") === "OK"; + } catch (err) { + logger.error({ err, workerId }, "[WorkerEvent] nonce store unavailable; rejecting event"); + return false; + } + } + workerNonceMemory.forEach((expiresAt, existing) => { + if (expiresAt <= now) workerNonceMemory.delete(existing); + }); + if (workerNonceMemory.has(key)) return false; + workerNonceMemory.set(key, now + ttlMs); + return true; +} + const workerLimiter = rateLimit({ windowMs: parseInt(process.env.RATE_LIMIT_WORKER_WINDOW_MS ?? "60000", 10), max: parseInt(process.env.RATE_LIMIT_WORKER_MAX ?? "500", 10), standardHeaders: "draft-7", legacyHeaders: false, keyGenerator: safeIpKey, - ...(createRateLimitStore ? { store: createRateLimitStore("ndsep:worker:") } : {}), + ...(createRateLimitStore ? { store: createRateLimitStore("ndsep:worker:"), passOnStoreError: false } : {}), }); async function startServer() { @@ -277,7 +298,18 @@ async function startServer() { ); // ── Body parsers ───────────────────────────────────────────────────────── - app.use(express.json({ limit: "2mb" })); + app.use(express.json({ + limit: "2mb", + verify: (req, _res, buffer) => { + const path = req.url?.split("?", 1)[0]; + if (path === "/api/workers/event") { + (req as express.Request & { rawWorkerEventBody?: Buffer }).rawWorkerEventBody = Buffer.from(buffer); + } + if (path?.startsWith("/api/mojaloop/transfers/")) { + (req as express.Request & { rawMojaloopCallbackBody?: Buffer }).rawMojaloopCallbackBody = Buffer.from(buffer); + } + }, + })); app.use(express.urlencoded({ limit: "2mb", extended: true })); // ── WAF + API Gateway ──────────────────────────────────────────────────── @@ -463,7 +495,7 @@ async function startServer() { const runningWorkers = workers.filter(w => w.status === "running").length; const totalWorkers = workers.length; - const ready = dbOk; + const ready = dbOk && (!isProd || redisOk); res.status(ready ? 200 : 503).json({ status: ready ? "ready" : "not_ready", checks: { @@ -612,6 +644,42 @@ async function startServer() { ); } catch (e) { logger.debug({ err: e instanceof Error ? e.message : String(e) }, "[Metrics] Kafka metrics unavailable"); } + // Webhook shadow queue telemetry: aggregate-only counters with no delivery identifiers. + try { + const { getWebhookDeliveryQueueMode, getWebhookShadowMetrics } = await import("../webhookDelivery"); + const metrics = getWebhookShadowMetrics(); + const mode = getWebhookDeliveryQueueMode(); + lines.push( + "# HELP ndsep_webhook_shadow_mode Whether webhook shadow queue mode is enabled (1=shadow)", + "# TYPE ndsep_webhook_shadow_mode gauge", + `ndsep_webhook_shadow_mode ${mode === "shadow" ? 1 : 0}`, + "# HELP ndsep_webhook_queue_active_mode Whether asynchronous webhook queue mode is enabled (1=active)", + "# TYPE ndsep_webhook_queue_active_mode gauge", + `ndsep_webhook_queue_active_mode ${mode === "active" ? 1 : 0}`, + "# HELP ndsep_webhook_queue_active_enqueued_total Active queue intents durably accepted for worker dispatch", + "# TYPE ndsep_webhook_queue_active_enqueued_total counter", + `ndsep_webhook_queue_active_enqueued_total ${metrics.activeEnqueued}`, + "# HELP ndsep_webhook_shadow_enqueued_total Shadow queue intents durably inserted", + "# TYPE ndsep_webhook_shadow_enqueued_total counter", + `ndsep_webhook_shadow_enqueued_total ${metrics.enqueued}`, + "# HELP ndsep_webhook_shadow_enqueue_errors_total Shadow queue intent persistence failures", + "# TYPE ndsep_webhook_shadow_enqueue_errors_total counter", + `ndsep_webhook_shadow_enqueue_errors_total ${metrics.enqueueErrors}`, + "# HELP ndsep_webhook_shadow_finalizations_total Shadow queue terminal finalizations by outcome", + "# TYPE ndsep_webhook_shadow_finalizations_total counter", + `ndsep_webhook_shadow_finalizations_total{outcome="delivered"} ${metrics.finalizedDelivered}`, + `ndsep_webhook_shadow_finalizations_total{outcome="dead"} ${metrics.finalizedDead}`, + "# HELP ndsep_webhook_shadow_finalization_errors_total Shadow queue terminal finalization failures", + "# TYPE ndsep_webhook_shadow_finalization_errors_total counter", + `ndsep_webhook_shadow_finalization_errors_total ${metrics.finalizationErrors}`, + "# HELP ndsep_webhook_shadow_last_enqueued_unixtime Most recent successfully inserted shadow intent", + "# TYPE ndsep_webhook_shadow_last_enqueued_unixtime gauge", + `ndsep_webhook_shadow_last_enqueued_unixtime ${metrics.lastEnqueuedAtSeconds}`, + ); + } catch (e) { + logger.warn({ err: e instanceof Error ? e.message : String(e) }, "[Metrics] Webhook shadow metrics unavailable"); + } + // Platform info lines.push( `# HELP ndsep_info Platform info`, @@ -650,10 +718,40 @@ async function startServer() { ); // ── Worker event relay endpoint ─────────────────────────────────────────── - app.post("/api/workers/event", (req, res) => { - const { event, data } = req.body; - if (event && data) broadcast(event, data); - res.json({ ok: true }); + // Every production event must be authenticated by a supervised worker using a + // shared secret injected at deployment time. The signed string includes a body + // hash, identity, timestamp and nonce; the nonce is claimed atomically in Redis. + app.post("/api/workers/event", async (req, res) => { + const workerId = String(req.header("x-ndsep-worker-id") ?? ""); + const timestamp = String(req.header("x-ndsep-event-timestamp") ?? ""); + const nonce = String(req.header("x-ndsep-event-nonce") ?? ""); + const signature = String(req.header("x-ndsep-event-signature") ?? ""); + const shapeError = validateWorkerEventShape(workerId, req.body?.event, req.body?.data); + if (shapeError) { + res.status(400).json({ error: "invalid_worker_event", detail: shapeError }); + return; + } + const rawBody = (req as express.Request & { rawWorkerEventBody?: Buffer }).rawWorkerEventBody; + if (!rawBody) { + logger.error({ workerId }, "[WorkerEvent] exact request bytes unavailable; rejecting event"); + res.status(503).json({ error: "worker_event_verification_unavailable" }); + return; + } + const verificationError = verifyWorkerEventSignature(process.env.WORKER_EVENT_HMAC_SECRET ?? "", { + workerId, timestamp, nonce, signature, rawBody, + }); + if (verificationError) { + logger.warn({ workerId, reason: verificationError }, "[WorkerEvent] rejected unauthenticated event"); + res.status(401).json({ error: "worker_event_authentication_failed" }); + return; + } + if (!(await claimWorkerEventNonce(workerId, nonce))) { + logger.warn({ workerId }, "[WorkerEvent] rejected replayed event or unavailable nonce store"); + res.status(409).json({ error: "worker_event_replay_or_nonce_store_unavailable" }); + return; + } + broadcast(req.body.event, { ...req.body.data, _worker_id: workerId, _event_timestamp: timestamp }); + res.status(202).json({ accepted: true, workerId, timestamp }); }); // ── Worker status endpoint ──────────────────────────────────────────────── @@ -744,17 +842,19 @@ async function startServer() { const data = await generateAuditReturnData(year); const pdfBuffer = await generateAuditReturnPdf(data); let finalBuffer = pdfBuffer; + let cryptographicallySigned = false; try { finalBuffer = await signPdf(pdfBuffer); + cryptographicallySigned = true; const certInfo = getSigningCertInfo(); logger.info({ year, certSubject: certInfo.subject }, "Audit return PDF signed"); } catch (signErr: any) { - logger.warn({ msg: signErr.message }, "PDF signing failed — serving unsigned"); + logger.error({ msg: signErr.message }, "PDF signing failed — refusing to label an unsigned audit return as signed"); } const dateStr = new Date().toISOString().slice(0, 10); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `attachment; filename="NDSEP-Annual-Audit-Return-${year}-${dateStr}.pdf"`); - res.setHeader("X-NDSEP-Signed", "true"); + res.setHeader("X-NDSEP-Signed", String(cryptographicallySigned)); res.send(finalBuffer); } catch (err: unknown) { logger.error({ err }, "Audit return PDF generation failed"); @@ -1337,6 +1437,8 @@ async function startServer() { const database = await getDb(); if (!database || !getPool()) throw new Error("Database initialization failed after migrations"); logger.info("[DB] Migrations applied and pool initialized successfully"); + startDurableOutbox(); + startPaymentCommandProcessor(); } else { logger.warn("[DB] Explicit test-only migration gate bypass enabled"); } @@ -1351,12 +1453,9 @@ async function startServer() { app.use(traceMiddleware()); - try { - const pool = getPool(); - if (pool) await initWebhookSystem(pool); - } catch (err) { - logger.warn({ err }, "[Startup] Webhook system init failed — webhooks disabled"); - } + const webhookPool = getPool(); + if (!webhookPool) throw new Error("Webhook schema verification requires an initialized PostgreSQL pool"); + await initWebhookSystem(webhookPool); if (!skipDatabaseGate) { const report = await verifyMigrations(); @@ -1384,27 +1483,15 @@ async function startServer() { logger.warn({ err }, "[Startup] CQRS projections init skipped"); } - try { - const { enableRowLevelSecurity } = await import("../multiTenancy"); - await enableRowLevelSecurity(); - } catch (err) { - logger.warn({ err }, "[Startup] Multi-tenancy init skipped"); - } + const { enableRowLevelSecurity } = await import("../multiTenancy"); + await enableRowLevelSecurity(); - try { - const { initMarketplace, mountDeveloperPortal } = await import("../marketplace"); - await initMarketplace(); - mountDeveloperPortal(app); - } catch (err) { - logger.warn({ err }, "[Startup] API marketplace init skipped"); - } + const { initMarketplace, mountDeveloperPortal } = await import("../marketplace"); + await initMarketplace(); + mountDeveloperPortal(app); - try { - const { initFeatureFlags } = await import("../featureFlags/index"); - await initFeatureFlags(); - } catch (err) { - logger.warn({ err }, "[Startup] Feature flags init skipped"); - } + const { initFeatureFlags } = await import("../featureFlags/index"); + await initFeatureFlags(); try { const { initRealtimeServer } = await import("../realtime"); @@ -1480,6 +1567,8 @@ async function startServer() { stopInvoiceOverdueScheduler(); stopNationalReportScheduler(); stopSlaBreachScheduler(); + stopDurableOutbox(); + stopPaymentCommandProcessor(); try { const { stopSessionCleanup } = await import("../security/sessionHardening"); stopSessionCleanup(); } catch { /* ok */ } logger.info("[Shutdown] All schedulers and workers stopped"); diff --git a/server/auditHashChain.postgres.integration.test.ts b/server/auditHashChain.postgres.integration.test.ts new file mode 100644 index 00000000..fbdae3ee --- /dev/null +++ b/server/auditHashChain.postgres.integration.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { Client } from "pg"; + +const databaseUrl = process.env.AUDIT_HASH_CHAIN_TEST_DATABASE_URL; + +function isDisposableLocalPostgresUrl(value: string): boolean { + const parsed = new URL(value); + const databaseName = parsed.pathname.replace(/^\//, "").toLowerCase(); + return ( + parsed.protocol === "postgresql:" + && ["127.0.0.1", "localhost"].includes(parsed.hostname) + && /(?:test|ci|synthetic|integration|e2e)/.test(databaseName) + ); +} + +const shouldRun = typeof databaseUrl === "string" && isDisposableLocalPostgresUrl(databaseUrl); + +if (databaseUrl && !shouldRun) { + throw new Error("AUDIT_HASH_CHAIN_TEST_DATABASE_URL must identify a localhost disposable test database"); +} + +function expectedHash(row: { + action: string; + resource_type: string | null; + resource_id: string | number | null; + user_id: string | number | null; + details: string; + created_at: string; +}, previousHash: string): string { + return crypto.createHash("sha256").update([ + previousHash, + row.action, + row.resource_type ?? "", + row.resource_id == null ? "" : String(row.resource_id), + row.user_id == null ? "" : String(row.user_id), + row.details, + row.created_at, + ].join("|")).digest("hex"); +} + +describe.skipIf(!shouldRun)("audit-log hash-chain PostgreSQL integration", () => { + let client: Client; + let migration: string; + + beforeAll(async () => { + if (!databaseUrl) throw new Error("AUDIT_HASH_CHAIN_TEST_DATABASE_URL is required"); + client = new Client({ connectionString: databaseUrl }); + await client.connect(); + await client.query("CREATE EXTENSION IF NOT EXISTS pgcrypto"); + await client.query("DROP TABLE IF EXISTS audit_logs CASCADE"); + await client.query(` + CREATE TABLE audit_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER, + organization_id INTEGER, + action VARCHAR(128) NOT NULL, + resource_type VARCHAR(64), + resource_id INTEGER, + details TEXT, + ip_address VARCHAR(64), + user_agent TEXT, + metadata JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `); + migration = await readFile(new URL("../drizzle/0043_audit_log_hash_chain_durable.sql", import.meta.url), "utf8"); + await client.query(migration); + }); + + afterAll(async () => { + await client?.end(); + }); + + it("assigns a deterministic database-owned chain and preserves an ordered predecessor link", async () => { + await client.query( + `INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, created_at) + VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12)`, + [ + 7, "rollback_drill_started", "deployment", 1, '{"drill":"staging-rollback-a1b2c3d4"}', "2026-09-02T10:00:00Z", + 7, "rollback_drill_completed", "deployment", 1, '{"drill":"staging-rollback-a1b2c3d4"}', "2026-09-02T10:00:01Z", + ], + ); + + const rows = await client.query<{ + id: number; + action: string; + resource_type: string | null; + resource_id: number | null; + user_id: number | null; + details: string; + created_at: string; + previous_hash: string | null; + hash_chain: string; + }>(`SELECT id, action, resource_type, resource_id, user_id, + COALESCE(details::text, '{}') AS details, + created_at::text AS created_at, previous_hash, hash_chain + FROM audit_logs + ORDER BY id`); + + expect(rows.rows).toHaveLength(2); + const [first, second] = rows.rows; + const firstExpected = expectedHash(first, "GENESIS"); + const secondExpected = expectedHash(second, firstExpected); + expect(first.previous_hash).toBeNull(); + expect(first.hash_chain).toBe(firstExpected); + expect(second.previous_hash).toBe(firstExpected); + expect(second.hash_chain).toBe(secondExpected); + }); + + it("rejects caller-supplied chain values and reports a tampered persisted chain without repairing it", async () => { + await expect(client.query( + `INSERT INTO audit_logs (action, details, created_at, hash_chain) + VALUES ($1, $2, $3, $4)`, + ["caller_hash_attempt", "{}", "2026-09-02T10:00:02Z", "a".repeat(64)], + )).rejects.toThrow("database managed"); + + const originalDatabaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = databaseUrl; + vi.resetModules(); + try { + const { verifyAuditLogIntegrity } = await import("./auditVerification"); + await expect(verifyAuditLogIntegrity()).resolves.toMatchObject({ verified: true, totalEntries: 2, verifiedEntries: 2, breaks: [] }); + + await client.query("ALTER TABLE audit_logs DISABLE TRIGGER audit_logs_assign_hash_chain"); + try { + await client.query("UPDATE audit_logs SET hash_chain = $1 WHERE id = $2", ["b".repeat(64), 2]); + } finally { + await client.query("ALTER TABLE audit_logs ENABLE TRIGGER audit_logs_assign_hash_chain"); + } + + await expect(verifyAuditLogIntegrity()).resolves.toMatchObject({ verified: false, firstBreak: 2, verifiedEntries: 2 }); + const tampered = await client.query<{ hash_chain: string }>("SELECT hash_chain FROM audit_logs WHERE id = 2"); + expect(tampered.rows[0]?.hash_chain).toBe("b".repeat(64)); + } finally { + if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalDatabaseUrl; + vi.resetModules(); + } + }); + + it("keeps the runtime audit verifier read-only and migration-owned", async () => { + const verifier = await readFile(new URL("./auditVerification.ts", import.meta.url), "utf8"); + expect(verifier).not.toMatch(/ALTER\s+TABLE\s+audit_logs/i); + expect(verifier).not.toMatch(/UPDATE\s+audit_logs\s+SET\s+hash_chain/i); + expect(verifier).toContain("audit_logs hash-chain columns are missing"); + expect(verifier).toContain("[MISSING]"); + }); +}); diff --git a/server/auditVerification.ts b/server/auditVerification.ts index d006b480..04b0d09c 100644 --- a/server/auditVerification.ts +++ b/server/auditVerification.ts @@ -78,16 +78,30 @@ export async function verifyAuditLogIntegrity( const maxEntries = options?.limit ?? Infinity; try { - // Check if hash_chain column exists const colCheck = await pool.query( `SELECT column_name FROM information_schema.columns - WHERE table_name = 'audit_logs' AND column_name = 'hash_chain'` + WHERE table_schema = 'public' + AND table_name = 'audit_logs' + AND column_name IN ('previous_hash', 'hash_chain')` ); + if (colCheck.rows.length !== 2) { + throw new Error("audit_logs hash-chain columns are missing; apply the authoritative migration before verification"); + } - if (colCheck.rows.length === 0) { - // hash_chain column doesn't exist yet — add it - await pool.query(`ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS hash_chain TEXT`); - logger.info("[AuditVerify] Added hash_chain column to audit_logs"); + if (lastId > 0) { + const predecessor = await pool.query( + `SELECT hash_chain + FROM audit_logs + WHERE id <= $1 + ORDER BY id DESC + LIMIT 1`, + [lastId] + ); + const predecessorHash = predecessor.rows[0]?.hash_chain; + if (!predecessorHash) { + throw new Error("cannot begin partial audit verification without a predecessor hash"); + } + previousHash = predecessorHash; } while (totalEntries < maxEntries) { @@ -95,7 +109,7 @@ export async function verifyAuditLogIntegrity( `SELECT id, action, resource_type, resource_id, user_id, COALESCE(details::text, '{}') AS details, created_at::text AS created_at, - hash_chain + previous_hash, hash_chain FROM audit_logs WHERE id > $1 ORDER BY id ASC @@ -109,22 +123,16 @@ export async function verifyAuditLogIntegrity( totalEntries++; const expected = computeEntryHash(row, previousHash); - if (row.hash_chain && row.hash_chain !== expected) { + if (!row.hash_chain || row.previous_hash !== (previousHash === "GENESIS" ? null : previousHash) || row.hash_chain !== expected) { breaks.push({ entryId: row.id, position: totalEntries, expectedHash: expected, - storedHash: row.hash_chain, + storedHash: row.hash_chain ?? "[MISSING]", }); - } else if (!row.hash_chain) { - // Backfill missing hashes - await pool.query( - `UPDATE audit_logs SET hash_chain = $1 WHERE id = $2`, - [expected, row.id] - ); } - previousHash = expected; + previousHash = row.hash_chain ?? expected; verifiedEntries++; lastId = row.id; @@ -159,27 +167,5 @@ export async function verifyAuditLogIntegrity( ); } - return result; -} - -/** - * Middleware helper to add hash chain to new audit log entries. - * Call this before inserting a new audit log row. - */ -export async function computeNextAuditHash( - pool: Pool, - entry: { - action: string; - resource_type: string; - resource_id: string; - user_id: string; - details: string; - created_at: string; - } -): Promise { - const { rows } = await pool.query( - `SELECT hash_chain FROM audit_logs ORDER BY id DESC LIMIT 1` - ); - const previousHash = rows[0]?.hash_chain ?? "GENESIS"; - return computeEntryHash(entry, previousHash); + return result; } diff --git a/server/authMiddleware.ts b/server/authMiddleware.ts index e9ceceab..160d793a 100644 --- a/server/authMiddleware.ts +++ b/server/authMiddleware.ts @@ -1,7 +1,9 @@ /** - * NDSEP Authentication Middleware for Express routes - * Supports dual auth: Keycloak OIDC (when KEYCLOAK_ENABLED=true) + internal session. - * Used to protect PDF download endpoints and other non-tRPC routes. + * NDSEP Authentication Middleware for Express routes. + * + * Production accepts only a cryptographically verified Keycloak bearer token + * mapped to a persisted local user. Internal sessions are development/test + * tooling and cannot authorize production HTTP routes. */ import type { Request, Response, NextFunction } from "express"; import { parse as parseCookieHeader } from "cookie"; @@ -11,95 +13,94 @@ import { logger } from "./logger"; import { getUserByOpenId } from "./db"; import { keycloakValidate } from "./middlewareExtensions"; -const KEYCLOAK_ENABLED = process.env.KEYCLOAK_ENABLED === "true"; +function isProduction(): boolean { + return process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; +} + +function keycloakEnabled(): boolean { + return process.env.KEYCLOAK_ENABLED === "true"; +} function extractToken(req: Request): string | undefined { if (req.cookies?.[COOKIE_NAME]) return req.cookies[COOKIE_NAME] as string; const header = req.headers.cookie; if (!header) return undefined; - const parsed = parseCookieHeader(header); - return parsed[COOKIE_NAME] || undefined; + return parseCookieHeader(header)[COOKIE_NAME]; } function extractBearerToken(req: Request): string | undefined { const auth = req.headers.authorization; if (!auth?.startsWith("Bearer ")) return undefined; - return auth.slice(7); + const token = auth.slice(7).trim(); + return token || undefined; } -/** - * Validate via Keycloak OIDC if enabled and Bearer token present. - * Returns { valid, user } or null if Keycloak path not applicable. - */ async function tryKeycloakAuth(req: Request): Promise<{ sub: string; roles: string[]; username?: string } | null> { - if (!KEYCLOAK_ENABLED) return null; + if (!keycloakEnabled()) return null; const bearer = extractBearerToken(req); if (!bearer) return null; - try { const result = await keycloakValidate(bearer); - if (!result.valid) return null; - return { sub: result.sub ?? "", roles: result.roles, username: result.username }; - } catch (err) { - logger.warn({ err }, "[Keycloak] Token validation failed, falling back to session"); + if (!result.valid || !result.sub) return null; + return { sub: result.sub, roles: result.roles, username: result.username }; + } catch (error) { + logger.warn({ err: error }, "[Keycloak] token validation failed"); return null; } } +function unauthorized(res: Response, message: string): void { + res.status(401).json({ error: message }); +} + /** - * requireSession: validates via Keycloak OIDC (if enabled) or session cookie. - * Attaches user to req. Returns 401 if no valid auth. + * requireSession attaches a persisted user to the request. + * In production it accepts only Keycloak bearer tokens and never falls back to + * session cookies or creates an ephemeral local identity for an unknown `sub`. */ export async function requireSession(req: Request, res: Response, next: NextFunction): Promise { try { - // Path 1: Keycloak OIDC Bearer token const kcUser = await tryKeycloakAuth(req); if (kcUser) { - const user = kcUser.sub ? await getUserByOpenId(kcUser.sub) : null; - if (user) { - (req as any).sessionUser = user; - next(); + const user = await getUserByOpenId(kcUser.sub); + if (!user) { + res.status(403).json({ error: "Keycloak identity is not provisioned in NDSEP" }); return; } - // Keycloak token valid but user not in local DB — still set basic info - (req as any).sessionUser = { - id: 0, - openId: kcUser.sub, - username: kcUser.username ?? "keycloak-user", - role: kcUser.roles.includes("admin") ? "admin" : kcUser.roles.includes("government_staff") ? "government_staff" : "user", - }; + (req as any).sessionUser = user; next(); return; } - // Path 2: Internal session cookie + if (isProduction()) { + unauthorized(res, "A valid Keycloak bearer token is required"); + return; + } + const token = extractToken(req); if (!token) { - res.status(401).json({ error: "Authentication required" }); + unauthorized(res, "Authentication required"); return; } const session = await sdk.verifySession(token); if (!session) { - res.status(401).json({ error: "Invalid or expired session" }); + unauthorized(res, "Invalid or expired session"); return; } const user = await getUserByOpenId(session.openId); if (!user) { - res.status(401).json({ error: "User not found" }); + unauthorized(res, "User not found"); return; } (req as any).sessionUser = user; next(); - } catch (err) { - logger.warn({ err }, "[requireSession] Session validation failed"); - res.status(401).json({ error: "Authentication required" }); + } catch (error) { + logger.warn({ err: error }, "[requireSession] authorization failed"); + unauthorized(res, "Authentication required"); } } -/** - * requireAdmin: validates session AND checks that user has admin role. - * Returns 403 if user is not an admin. - */ +/** Require a persisted NDSEP administrator after the same authentication gate. */ export async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise { await requireSession(req, res, async () => { const user = (req as any).sessionUser; diff --git a/server/cache.ts b/server/cache.ts index 945a0a4a..07d5263d 100644 --- a/server/cache.ts +++ b/server/cache.ts @@ -19,6 +19,22 @@ const REDIS_URL = process.env.REDIS_URL ?? "redis://localhost:6379"; const REDIS_ENABLED = (process.env.REDIS_ENABLED ?? "true") === "true"; const REDIS_TLS_ENABLED = process.env.REDIS_TLS === "true"; const REDIS_TLS_CA = process.env.REDIS_TLS_CA_PATH; +const IS_PRODUCTION = process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; + +function validateRedisConfiguration(): void { + if (!IS_PRODUCTION || !REDIS_ENABLED) return; + if (!/^rediss:\/\//.test(REDIS_URL)) { + throw new Error("Enabled production Redis requires a rediss:// endpoint"); + } + if (!REDIS_TLS_ENABLED || process.env.REDIS_TLS_REJECT_UNAUTHORIZED === "false") { + throw new Error("Enabled production Redis requires TLS certificate verification"); + } + if (!REDIS_TLS_CA || !fs.existsSync(REDIS_TLS_CA)) { + throw new Error("Enabled production Redis requires a readable REDIS_TLS_CA_PATH"); + } +} + +validateRedisConfiguration(); let hits = 0; let misses = 0; @@ -51,7 +67,7 @@ if (REDIS_ENABLED) { redis.on("connect", () => { connected = true; - logger.info(`[Redis] Connected to ${REDIS_URL}`); + logger.info(`[Redis] Connected to ${REDIS_URL.replace(/:\/\/.*@/, "://**@")}`); }); redis.on("ready", () => { connected = true; }); diff --git a/server/db.ts b/server/db.ts index efee14bd..6572624a 100644 --- a/server/db.ts +++ b/server/db.ts @@ -3,6 +3,7 @@ import { and, desc, eq, gte, ilike, isNotNull, lt, lte, or, sql, SQL } from "dri import { drizzle } from "drizzle-orm/node-postgres"; import pg from "pg"; import { getPgSslConfig } from "./dbSslConfig"; +import { getPrimaryDatabasePoolOptions } from "./dbPoolConfig"; import { encryptField } from "./encryption"; import { assets, @@ -72,13 +73,7 @@ export async function getDb() { _db = null; _pool = null; try { - _pool = new Pool({ - connectionString: PG_URL, - ssl: getPgSslConfig(), - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, - }); + _pool = new Pool(getPrimaryDatabasePoolOptions(PG_URL, getPgSslConfig())); _db = drizzle(_pool); } catch (error) { logger.warn({ data: error }, "[Database] Failed to connect:"); diff --git a/server/dbPoolConfig.test.ts b/server/dbPoolConfig.test.ts new file mode 100644 index 00000000..59c21c8d --- /dev/null +++ b/server/dbPoolConfig.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { getPrimaryDatabasePoolOptions } from "./dbPoolConfig"; + +describe("primary database pool configuration", () => { + it("applies bounded defaults, server-side timeouts, connection recycling, and test-only idle exit", () => { + expect( + getPrimaryDatabasePoolOptions("postgresql://user:password@localhost:5432/ndsep_test", false, { + NODE_ENV: "test", + }) + ).toMatchObject({ + connectionString: "postgresql://user:password@localhost:5432/ndsep_test", + ssl: false, + application_name: "ndsep-api", + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + statement_timeout: 30000, + query_timeout: 35000, + lock_timeout: 5000, + idle_in_transaction_session_timeout: 15000, + maxUses: 10000, + keepAlive: true, + allowExitOnIdle: true, + }); + }); + + it("accepts bounded operational overrides without changing the database credential boundary", () => { + const options = getPrimaryDatabasePoolOptions("postgresql://user:password@db.example:5432/ndsep", { + rejectUnauthorized: true, + }, { + NODE_ENV: "production", + DB_APPLICATION_NAME: "ndsep-api-blue", + DB_POOL_MAX: "32", + DB_IDLE_TIMEOUT_MS: "45000", + DB_CONNECTION_TIMEOUT_MS: "7500", + DB_STATEMENT_TIMEOUT_MS: "60000", + DB_QUERY_TIMEOUT_MS: "65000", + DB_LOCK_TIMEOUT_MS: "8000", + DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: "20000", + DB_POOL_MAX_USES: "20000", + }); + + expect(options).toMatchObject({ + connectionString: "postgresql://user:password@db.example:5432/ndsep", + ssl: { rejectUnauthorized: true }, + application_name: "ndsep-api-blue", + max: 32, + idleTimeoutMillis: 45000, + connectionTimeoutMillis: 7500, + statement_timeout: 60000, + query_timeout: 65000, + lock_timeout: 8000, + idle_in_transaction_session_timeout: 20000, + maxUses: 20000, + allowExitOnIdle: false, + }); + }); + + it("fails fast for invalid, zero, or unbounded operational settings", () => { + expect(() => + getPrimaryDatabasePoolOptions("postgresql://user:password@localhost:5432/ndsep_test", false, { + DB_POOL_MAX: "0", + }) + ).toThrow("DB_POOL_MAX must be between 1 and 100"); + expect(() => + getPrimaryDatabasePoolOptions("postgresql://user:password@localhost:5432/ndsep_test", false, { + DB_STATEMENT_TIMEOUT_MS: "forever", + }) + ).toThrow("DB_STATEMENT_TIMEOUT_MS must be an integer between 1000 and 300000"); + expect(() => + getPrimaryDatabasePoolOptions("postgresql://user:password@localhost:5432/ndsep_test", false, { + DB_POOL_MAX_USES: "1000001", + }) + ).toThrow("DB_POOL_MAX_USES must be between 100 and 1000000"); + }); +}); diff --git a/server/dbPoolConfig.ts b/server/dbPoolConfig.ts new file mode 100644 index 00000000..f9a757dd --- /dev/null +++ b/server/dbPoolConfig.ts @@ -0,0 +1,94 @@ +import type { PoolConfig } from "pg"; + +const DEFAULTS = { + max: 20, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + statement_timeout: 30_000, + query_timeout: 35_000, + lock_timeout: 5_000, + idle_in_transaction_session_timeout: 15_000, + maxUses: 10_000, +} as const; + +function boundedInteger( + environment: NodeJS.ProcessEnv, + name: string, + fallback: number, + minimum: number, + maximum: number +): number { + const raw = environment[name]; + if (raw === undefined || raw === "") return fallback; + if (!/^\d+$/.test(raw)) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`); + } + const value = Number(raw); + if (value < minimum || value > maximum) { + throw new Error(`${name} must be between ${minimum} and ${maximum}`); + } + return value; +} + +/** + * Applies bounded operational controls to the primary PostgreSQL pool. + * Database credentials and TLS settings remain supplied by the existing + * configuration/secret boundary; this function never introduces defaults for them. + */ +export function getPrimaryDatabasePoolOptions( + connectionString: string, + ssl: PoolConfig["ssl"], + environment: NodeJS.ProcessEnv = process.env +): PoolConfig { + return { + connectionString, + ssl, + application_name: environment.DB_APPLICATION_NAME ?? "ndsep-api", + max: boundedInteger(environment, "DB_POOL_MAX", DEFAULTS.max, 1, 100), + idleTimeoutMillis: boundedInteger( + environment, + "DB_IDLE_TIMEOUT_MS", + DEFAULTS.idleTimeoutMillis, + 1_000, + 300_000 + ), + connectionTimeoutMillis: boundedInteger( + environment, + "DB_CONNECTION_TIMEOUT_MS", + DEFAULTS.connectionTimeoutMillis, + 100, + 60_000 + ), + statement_timeout: boundedInteger( + environment, + "DB_STATEMENT_TIMEOUT_MS", + DEFAULTS.statement_timeout, + 1_000, + 300_000 + ), + query_timeout: boundedInteger( + environment, + "DB_QUERY_TIMEOUT_MS", + DEFAULTS.query_timeout, + 1_000, + 300_000 + ), + lock_timeout: boundedInteger( + environment, + "DB_LOCK_TIMEOUT_MS", + DEFAULTS.lock_timeout, + 100, + 60_000 + ), + idle_in_transaction_session_timeout: boundedInteger( + environment, + "DB_IDLE_IN_TRANSACTION_TIMEOUT_MS", + DEFAULTS.idle_in_transaction_session_timeout, + 1_000, + 300_000 + ), + maxUses: boundedInteger(environment, "DB_POOL_MAX_USES", DEFAULTS.maxUses, 100, 1_000_000), + keepAlive: true, + allowExitOnIdle: environment.NODE_ENV === "test", + }; +} diff --git a/server/dbSslConfig.test.ts b/server/dbSslConfig.test.ts index eb629b83..9f0980ab 100644 --- a/server/dbSslConfig.test.ts +++ b/server/dbSslConfig.test.ts @@ -22,6 +22,28 @@ describe("dbSslConfig", () => { expect(config === false || (typeof config === "object" && config.rejectUnauthorized === false)).toBe(true); }); + it("fails closed when certificate verification is explicitly disabled in production", async () => { + process.env.NODE_ENV = "production"; + process.env.DB_SSL_REJECT_UNAUTHORIZED = "false"; + const { getPgSslConfig } = await import("./dbSslConfig"); + expect(() => getPgSslConfig()).toThrow("DB_SSL_REJECT_UNAUTHORIZED=false is prohibited in production"); + }); + + it("fails closed when a configured production CA cannot be read", async () => { + process.env.NODE_ENV = "production"; + process.env.DB_SSL_CA = "/path/that/does/not/exist/ndsep-ca.pem"; + const { getPgSslConfig } = await import("./dbSslConfig"); + expect(() => getPgSslConfig()).toThrow("DB_SSL_CA could not be read in production"); + }); + + it("enables verified TLS for an explicit read-replica connection URL", async () => { + process.env.NODE_ENV = "test"; + const { getPgSslConfig } = await import("./dbSslConfig"); + expect(getPgSslConfig("postgresql://reader:password@replica.internal:5432/ndsep?sslmode=verify-full")).toMatchObject({ + rejectUnauthorized: true, + }); + }); + it("should enforce SSL in production mode", async () => { process.env.NODE_ENV = "production"; process.env.DB_SSL_REJECT_UNAUTHORIZED = "true"; diff --git a/server/dbSslConfig.ts b/server/dbSslConfig.ts index 196cbc8b..ebe07fcc 100644 --- a/server/dbSslConfig.ts +++ b/server/dbSslConfig.ts @@ -23,14 +23,17 @@ export interface PgSslConfig { * - In production: SSL enabled with certificate verification (if CA is provided) * - In development: SSL disabled unless DATABASE_URL contains sslmode=require */ -export function getPgSslConfig(): PgSslConfig | false { +export function getPgSslConfig(databaseUrl: string = process.env.DATABASE_URL ?? ""): PgSslConfig | false { const isProduction = process.env.NODE_ENV === "production"; - const dbUrl = process.env.DATABASE_URL ?? ""; - const requiresSsl = dbUrl.includes("sslmode=require") || isProduction; + const dbUrl = databaseUrl; + const requiresSsl = /(?:[?&]|^)sslmode=(?:require|verify-ca|verify-full)(?:&|$)/i.test(dbUrl) || isProduction; if (!requiresSsl) return false; const caPath = process.env.DB_SSL_CA ?? ""; + if (isProduction && process.env.DB_SSL_REJECT_UNAUTHORIZED === "false") { + throw new Error("DB_SSL_REJECT_UNAUTHORIZED=false is prohibited in production"); + } const rejectUnauthorized = process.env.DB_SSL_REJECT_UNAUTHORIZED !== "false"; const config: PgSslConfig = { @@ -42,6 +45,9 @@ export function getPgSslConfig(): PgSslConfig | false { try { config.ca = fs.readFileSync(caPath, "utf8"); } catch (err) { + if (isProduction) { + throw new Error(`DB_SSL_CA could not be read in production: ${caPath}`, { cause: err }); + } logger.warn({ data: err }, `[DB SSL] Failed to read CA certificate from ${caPath}:`); } } diff --git a/server/envValidation.ts b/server/envValidation.ts index d1e943bf..2d28f83e 100644 --- a/server/envValidation.ts +++ b/server/envValidation.ts @@ -12,13 +12,15 @@ interface EnvRule { name: string; insecureDefaults: string[]; description: string; + minimumLength?: number; } const SECURITY_SENSITIVE_VARS: EnvRule[] = [ { name: "JWT_SECRET", insecureDefaults: ["", "dev-test-secret-key-at-least-32-chars-long"], - description: "Session signing key — unsigned tokens if missing", + minimumLength: 32, + description: "Session signing key — unsigned tokens if missing or predictable", }, { name: "FIELD_ENCRYPTION_KEY", @@ -38,16 +40,19 @@ const SECURITY_SENSITIVE_VARS: EnvRule[] = [ { name: "API_KEY_SALT", insecureDefaults: ["ndsep_api_key_salt_2026_production_default", ""], + minimumLength: 32, description: "API key hashing salt — predictable hashes if using default", }, { name: "WEBHOOK_SIGNING_SECRET", insecureDefaults: ["ndsep_webhook_signing_secret_2026_default", ""], + minimumLength: 32, description: "Webhook signature key — signatures forgeable with default", }, { name: "APISIX_ADMIN_KEY", insecureDefaults: ["CHANGE_ME_IN_PRODUCTION", ""], + minimumLength: 32, description: "APISIX admin API key — gateway admin accessible with known key", }, { @@ -55,6 +60,21 @@ const SECURITY_SENSITIVE_VARS: EnvRule[] = [ insecureDefaults: [""], description: "PostgreSQL connection string — server cannot function without DB", }, + { + name: "REDIS_URL", + insecureDefaults: ["", "redis://localhost:6379"], + description: "Redis transport — production rate limiting and replay protection require a non-default TLS endpoint", + }, + { + name: "WORKER_EVENT_HMAC_SECRET", + insecureDefaults: [""], + description: "Worker-event authentication secret — unsigned worker events must not be accepted", + }, + { + name: "CORS_ORIGINS", + insecureDefaults: ["", "*"], + description: "CORS origin allow-list — wildcard origins are not permitted in production", + }, ]; const SECTOR_API_KEYS: EnvRule[] = [ @@ -66,6 +86,48 @@ const SECTOR_API_KEYS: EnvRule[] = [ { name: "CBN_FINTECH_API_KEY", insecureDefaults: ["cbn-fintech-api-key-placeholder", ""], description: "CBN (fintech) regulator API key" }, ]; +function isPlaceholderValue(value: string): boolean { + return /(?:CHANGE_ME|PLACEHOLDER|_DEFAULT(?:_|$)|example\.)/i.test(value); +} + +function invalidProductionCorsOrigins(value: string): string[] { + const origins = value.split(",").map((origin) => origin.trim()).filter(Boolean); + if (origins.length === 0) return ["CORS_ORIGINS must contain at least one explicit HTTPS origin"]; + + const errors: string[] = []; + for (const origin of origins) { + try { + const url = new URL(origin); + if ( + url.protocol !== "https:" || + url.origin !== origin || + url.username || + url.password || + ["localhost", "127.0.0.1", "::1"].includes(url.hostname) + ) { + errors.push(origin); + } + } catch { + errors.push(origin); + } + } + return errors; +} + +function invalidProductionServiceUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol !== "https:" || + Boolean(url.username) || + Boolean(url.password) || + ["localhost", "127.0.0.1", "::1"].includes(url.hostname) + ); + } catch { + return true; + } +} + const INFRASTRUCTURE_VARS: EnvRule[] = [ { name: "LAKEHOUSE_S3_ACCESS_KEY", insecureDefaults: ["minioadmin"], description: "Lakehouse S3 access key — using MinIO dev default" }, { name: "LAKEHOUSE_S3_SECRET_KEY", insecureDefaults: ["minioadmin"], description: "Lakehouse S3 secret key — using MinIO dev default" }, @@ -77,13 +139,17 @@ const INFRASTRUCTURE_VARS: EnvRule[] = [ * In development: logs warnings for missing variables. */ export function validateEnvironment(): void { - const isProduction = process.env.NODE_ENV === "production"; + const isProduction = process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; const errors: string[] = []; const warnings: string[] = []; for (const rule of SECURITY_SENSITIVE_VARS) { const value = process.env[rule.name] ?? ""; - if (rule.insecureDefaults.includes(value)) { + if ( + rule.insecureDefaults.includes(value) || + isPlaceholderValue(value) || + (rule.minimumLength !== undefined && value.length < rule.minimumLength) + ) { if (isProduction) { errors.push(` ${rule.name}: ${rule.description}`); } else { @@ -92,9 +158,73 @@ export function validateEnvironment(): void { } } + if (isProduction) { + if (!/^rediss:\/\//.test(process.env.REDIS_URL ?? "")) { + errors.push(" REDIS_URL: production replay protection requires a rediss:// endpoint with a private CA/identity policy"); + } + const invalidCorsOrigins = invalidProductionCorsOrigins(process.env.CORS_ORIGINS ?? ""); + if (invalidCorsOrigins.length > 0) { + errors.push(` CORS_ORIGINS: production origins must be explicit HTTPS origins without paths, credentials, or localhost values (${invalidCorsOrigins.join(", ")})`); + } + if ((process.env.WORKER_EVENT_HMAC_SECRET ?? "").length < 32) { + errors.push(" WORKER_EVENT_HMAC_SECRET: must be a high-entropy secret of at least 32 characters"); + } + if (!/^[a-f0-9]{64}$/i.test(process.env.FIELD_ENCRYPTION_KEY ?? "")) { + errors.push(" FIELD_ENCRYPTION_KEY: must be exactly 32 random bytes encoded as 64 hexadecimal characters"); + } + if ((process.env.LAKEHOUSE_ENABLED ?? "false") === "true") { + if (!/^https:\/\//.test(process.env.LAKEHOUSE_CATALOG_URL ?? "")) { + errors.push(" LAKEHOUSE_CATALOG_URL: enabled production lakehouse requires an https:// catalog endpoint"); + } + for (const key of ["LAKEHOUSE_S3_ACCESS_KEY", "LAKEHOUSE_S3_SECRET_KEY"]) { + const value = process.env[key] ?? ""; + if (value.length < 16 || value === "minioadmin") { + errors.push(` ${key}: enabled production lakehouse requires a non-default credential of at least 16 characters`); + } + } + } + if ((process.env.PERMIFY_ENABLED ?? "false") === "true") { + if (!/^https:\/\//.test(process.env.PERMIFY_URL ?? "")) { + errors.push(" PERMIFY_URL: enabled production authorization requires an https:// endpoint"); + } + if ((process.env.PERMIFY_AUTH_TOKEN ?? "").length < 32) { + errors.push(" PERMIFY_AUTH_TOKEN: enabled production authorization requires a high-entropy bearer credential"); + } + } + const keycloakEnabled = process.env.KEYCLOAK_ENABLED; + if (keycloakEnabled !== "true") { + errors.push(" KEYCLOAK_ENABLED: production requires real Keycloak IAM and cannot use local-session or demo authentication"); + } + const keycloakUrl = process.env.KEYCLOAK_URL ?? ""; + const issuerUrl = process.env.KEYCLOAK_ISSUER_URL ?? keycloakUrl; + if (invalidProductionServiceUrl(keycloakUrl)) { + errors.push(" KEYCLOAK_URL: production IAM requires a non-local https:// endpoint without inline credentials"); + } + if (invalidProductionServiceUrl(issuerUrl)) { + errors.push(" KEYCLOAK_ISSUER_URL: production IAM requires a non-local https:// issuer endpoint without inline credentials"); + } + for (const name of ["KEYCLOAK_REALM", "KEYCLOAK_CLIENT_ID"]) { + const value = process.env[name] ?? ""; + if (value.length < 2 || isPlaceholderValue(value)) { + errors.push(` ${name}: production IAM requires a non-placeholder configured value`); + } + } + const analyticsUrl = process.env.DPCO_ANALYTICS_URL ?? ""; + if (invalidProductionServiceUrl(analyticsUrl)) { + errors.push(" DPCO_ANALYTICS_URL: production analytics gateway requires a non-local https:// endpoint without inline credentials"); + } + if ((process.env.DPCO_ANALYTICS_SERVICE_TOKEN ?? "").length < 32) { + errors.push(" DPCO_ANALYTICS_SERVICE_TOKEN: production analytics gateway requires a high-entropy service credential of at least 32 characters"); + } + } + for (const rule of SECTOR_API_KEYS) { const value = process.env[rule.name] ?? ""; - if (rule.insecureDefaults.includes(value)) { + if ( + rule.insecureDefaults.includes(value) || + isPlaceholderValue(value) || + (rule.minimumLength !== undefined && value.length < rule.minimumLength) + ) { if (isProduction) { warnings.push(` ${rule.name}: ${rule.description} — sector monitor will fail API calls`); } @@ -103,7 +233,11 @@ export function validateEnvironment(): void { for (const rule of INFRASTRUCTURE_VARS) { const value = process.env[rule.name] ?? ""; - if (rule.insecureDefaults.includes(value)) { + if ( + rule.insecureDefaults.includes(value) || + isPlaceholderValue(value) || + (rule.minimumLength !== undefined && value.length < rule.minimumLength) + ) { if (isProduction) { warnings.push(` ${rule.name}: ${rule.description}`); } diff --git a/server/envValidationHardening.test.ts b/server/envValidationHardening.test.ts new file mode 100644 index 00000000..e05148d6 --- /dev/null +++ b/server/envValidationHardening.test.ts @@ -0,0 +1,220 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateEnvironment } from "./envValidation"; + +const managedKeys = [ + "NODE_ENV", + "APP_ENV", + "KEYCLOAK_ENABLED", + "KEYCLOAK_URL", + "KEYCLOAK_ISSUER_URL", + "KEYCLOAK_REALM", + "KEYCLOAK_CLIENT_ID", + "DPCO_ANALYTICS_URL", + "DPCO_ANALYTICS_SERVICE_TOKEN", + "JWT_SECRET", + "FIELD_ENCRYPTION_KEY", + "TERMII_API_KEY", + "NDPC_PHONE_NUMBER", + "API_KEY_SALT", + "WEBHOOK_SIGNING_SECRET", + "APISIX_ADMIN_KEY", + "DATABASE_URL", + "REDIS_URL", + "WORKER_EVENT_HMAC_SECRET", + "CORS_ORIGINS", + "LAKEHOUSE_ENABLED", + "LAKEHOUSE_CATALOG_URL", + "LAKEHOUSE_S3_ACCESS_KEY", + "LAKEHOUSE_S3_SECRET_KEY", + "PERMIFY_ENABLED", + "PERMIFY_URL", + "PERMIFY_AUTH_TOKEN", +] as const; + +const savedEnvironment = Object.fromEntries( + managedKeys.map(key => [key, process.env[key]]) +); +const root = resolve(import.meta.dirname, ".."); + +function applyProductionEnvironment( + overrides: Record = {} +) { + const environment: Record = { + NODE_ENV: "production", + KEYCLOAK_ENABLED: "true", + KEYCLOAK_URL: "https://keycloak.internal.ndsep.gov.ng", + KEYCLOAK_ISSUER_URL: "https://identity.ndsep.gov.ng", + KEYCLOAK_REALM: "ndsep", + KEYCLOAK_CLIENT_ID: "ndsep-platform", + DPCO_ANALYTICS_URL: "https://analytics.internal.ndsep.gov.ng", + DPCO_ANALYTICS_SERVICE_TOKEN: "a".repeat(48), + JWT_SECRET: "j".repeat(32), + FIELD_ENCRYPTION_KEY: "a".repeat(64), + TERMII_API_KEY: "termii-production-api-key", + NDPC_PHONE_NUMBER: "+2348012345679", + API_KEY_SALT: "s".repeat(32), + WEBHOOK_SIGNING_SECRET: "w".repeat(32), + APISIX_ADMIN_KEY: "k".repeat(32), + DATABASE_URL: "postgresql://ndsep@db.internal/ndsep", + REDIS_URL: "rediss://cache.internal:6380", + WORKER_EVENT_HMAC_SECRET: "h".repeat(32), + CORS_ORIGINS: "https://ndsep.operations.gov.ng", + LAKEHOUSE_ENABLED: "false", + PERMIFY_ENABLED: "false", + }; + for (const key of managedKeys) { + const value = Object.hasOwn(overrides, key) ? overrides[key] : environment[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +afterEach(() => { + for (const key of managedKeys) { + const value = savedEnvironment[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("production environment hardening", () => { + it("accepts a complete production baseline without contacting external services", () => { + applyProductionEnvironment(); + expect(() => validateEnvironment()).not.toThrow(); + }); + + it("requires enabled real Keycloak IAM in production", () => { + for (const keycloakEnabled of [undefined, "false"]) { + applyProductionEnvironment({ KEYCLOAK_ENABLED: keycloakEnabled }); + expect(() => validateEnvironment()).toThrow( + /KEYCLOAK_ENABLED: production requires real Keycloak IAM/ + ); + } + }); + + it("rejects insecure Keycloak and analytics service routing configuration", () => { + applyProductionEnvironment({ + DPCO_ANALYTICS_URL: "http://localhost:8330", + DPCO_ANALYTICS_SERVICE_TOKEN: "short", + }); + expect(() => validateEnvironment()).toThrow( + /DPCO_ANALYTICS_URL[\s\S]*DPCO_ANALYTICS_SERVICE_TOKEN/ + ); + }); + + it("rejects insecure enabled Keycloak endpoints and placeholder identity settings", () => { + applyProductionEnvironment({ + KEYCLOAK_ENABLED: "true", + KEYCLOAK_URL: "http://localhost:8080", + KEYCLOAK_ISSUER_URL: "https://user:secret@issuer.example.internal", + KEYCLOAK_REALM: "", + KEYCLOAK_CLIENT_ID: "placeholder-client", + }); + expect(() => validateEnvironment()).toThrow( + /KEYCLOAK_URL[\s\S]*KEYCLOAK_ISSUER_URL[\s\S]*KEYCLOAK_REALM[\s\S]*KEYCLOAK_CLIENT_ID/ + ); + }); + + it("accepts an explicit secure enabled Keycloak configuration and APP_ENV production alias", () => { + applyProductionEnvironment({ + NODE_ENV: "test", + APP_ENV: "production", + KEYCLOAK_ENABLED: "true", + KEYCLOAK_URL: "https://keycloak.internal.ndsep.gov.ng", + KEYCLOAK_ISSUER_URL: "https://identity.ndsep.gov.ng", + KEYCLOAK_REALM: "ndsep", + KEYCLOAK_CLIENT_ID: "ndsep-platform", + }); + expect(() => validateEnvironment()).not.toThrow(); + }); + + it("rejects browser origins that are plaintext, local, credentialed, or path-bearing", () => { + for (const origin of [ + "http://ndsep.operations.gov.ng", + "https://localhost:3000", + "https://user:pass@ndsep.operations.gov.ng", + "https://ndsep.operations.gov.ng/api", + ]) { + applyProductionEnvironment({ CORS_ORIGINS: origin }); + expect(() => validateEnvironment()).toThrow( + /CORS_ORIGINS: production origins must be explicit HTTPS origins/ + ); + } + }); + + it("rejects weak or absent signing material instead of accepting application defaults", () => { + applyProductionEnvironment({ + API_KEY_SALT: "short", + WEBHOOK_SIGNING_SECRET: "ndsep_webhook_signing_secret_2026_default", + }); + expect(() => validateEnvironment()).toThrow( + /API_KEY_SALT: API key hashing salt[\s\S]*WEBHOOK_SIGNING_SECRET: Webhook signature key/ + ); + }); + + it("does not retain known development credentials in active runtime or worker-launch code", () => { + const files = [ + "server/_core/env.ts", + "server/workerManager.ts", + "orchestration/python/dpco_analytics/service.py", + ]; + const source = files + .map(file => readFileSync(resolve(root, file), "utf8")) + .join("\n"); + for (const insecureValue of [ + '"minioadmin"', + '"TLtest_default_key_ndsep_2026"', + '"ndsep_api_key_salt_2026_production_default"', + '"ndsep_webhook_signing_secret_2026_default"', + '"demo-ofac-key"', + '"demo-nibss-key"', + '"demo-cbn-key"', + ]) { + expect(source).not.toContain(insecureValue); + } + }); + + it("keeps credential-dependent Lakehouse and SMS integrations disabled until explicitly enabled", () => { + const environmentSource = readFileSync( + resolve(root, "server/_core/env.ts"), + "utf8" + ); + const managerSource = readFileSync( + resolve(root, "server/workerManager.ts"), + "utf8" + ); + const analyticsSource = readFileSync( + resolve(root, "orchestration/python/dpco_analytics/service.py"), + "utf8" + ); + expect(environmentSource).toContain( + 'keycloakEnabled: process.env.KEYCLOAK_ENABLED === "true"' + ); + expect(environmentSource).toContain( + 'lakehouseEnabled: process.env.LAKEHOUSE_ENABLED === "true"' + ); + expect(environmentSource).toContain( + 'termiiEnabled: process.env.TERMII_ENABLED === "true"' + ); + expect(managerSource).toMatch( + /LAKEHOUSE_ENABLED:\s*process\.env\.LAKEHOUSE_ENABLED === "true" \? "true" : "false"/ + ); + expect(analyticsSource).toContain( + 'LAKEHOUSE_ENABLED = os.getenv("LAKEHOUSE_ENABLED", "false").lower() == "true"' + ); + }); + + it("requires HTTPS catalog and non-default credentials when the lakehouse is enabled", () => { + applyProductionEnvironment({ + LAKEHOUSE_ENABLED: "true", + LAKEHOUSE_CATALOG_URL: "http://lakehouse.internal:8181", + LAKEHOUSE_S3_ACCESS_KEY: "minioadmin", + LAKEHOUSE_S3_SECRET_KEY: "minioadmin", + }); + expect(() => validateEnvironment()).toThrow( + /LAKEHOUSE_CATALOG_URL[\s\S]*LAKEHOUSE_S3_ACCESS_KEY[\s\S]*LAKEHOUSE_S3_SECRET_KEY/ + ); + }); +}); diff --git a/server/eventBus.durable.test.ts b/server/eventBus.durable.test.ts new file mode 100644 index 00000000..00e04698 --- /dev/null +++ b/server/eventBus.durable.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getPool: vi.fn(), + kafkaProduce: vi.fn(), + daprPublish: vi.fn(), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("./db", () => ({ getPool: mocks.getPool })); +vi.mock("./kafka", () => ({ kafkaProduce: mocks.kafkaProduce })); +vi.mock("./dapr", () => ({ daprPublish: mocks.daprPublish })); +vi.mock("./logger", () => ({ logger: mocks.logger })); + +import { processDurableOutbox, publishEvent, stopDurableOutbox } from "./eventBus"; + +const durableRow = { + id: "6f7b3b77-741a-4db1-a222-000000000001", + event_type: "breach.created", + topic: "ndsep.breach.created", + aggregate_id: "42", + aggregate_type: "breach_incident", + payload: { + type: "breach.created", + aggregateId: "42", + aggregateType: "breach_incident", + payload: { orgId: 7, severity: "high" }, + timestamp: "2026-09-01T12:00:00.000Z", + correlationId: "66a195d4-b43e-4d18-bc6c-000000000001", + }, + headers: { + "event-type": "breach.created", + "aggregate-type": "breach_incident", + "correlation-id": "66a195d4-b43e-4d18-bc6c-000000000001", + }, + user_id: 3, + correlation_id: "66a195d4-b43e-4d18-bc6c-000000000001", + attempts: 1, +}; + +function buildPool(rows: typeof durableRow[] = []) { + const client = { + query: vi.fn(async (sql: string) => { + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") return { rows: [] }; + if (sql.includes("WITH due AS")) return { rows }; + return { rows: [] }; + }), + release: vi.fn(), + }; + return { + query: vi.fn(async () => ({ rows: [] })), + connect: vi.fn(async () => client), + client, + }; +} + +describe("durable PostgreSQL event outbox", () => { + beforeEach(() => { + stopDurableOutbox(); + vi.clearAllMocks(); + mocks.daprPublish.mockResolvedValue(undefined); + }); + + it("persists the event then reschedules it when Kafka is unavailable", async () => { + const pool = buildPool([durableRow]); + mocks.getPool.mockReturnValue(pool); + mocks.kafkaProduce.mockResolvedValue(false); + + await expect(publishEvent("breach.created", "42", "breach_incident", { orgId: 7, severity: "high" }, 3)).resolves.toBe(true); + + const enqueueCall = pool.query.mock.calls.find(([sql]) => String(sql).includes("INSERT INTO domain_event_outbox")); + const rescheduleCall = pool.query.mock.calls.find(([sql]) => String(sql).includes("next_attempt_at")); + expect(enqueueCall).toBeDefined(); + expect(rescheduleCall).toBeDefined(); + expect(rescheduleCall?.[1][1]).toBeGreaterThanOrEqual(5); + expect(pool.client.query).toHaveBeenCalledWith("BEGIN"); + expect(pool.client.query).toHaveBeenCalledWith("COMMIT"); + expect(pool.client.release).toHaveBeenCalledOnce(); + }); + + it("marks a claimed durable event published only after Kafka and Dapr confirm delivery", async () => { + const pool = buildPool([durableRow]); + mocks.getPool.mockReturnValue(pool); + mocks.kafkaProduce.mockResolvedValue(true); + + await expect(processDurableOutbox()).resolves.toBe(1); + + const markPublishedCall = pool.query.mock.calls.find(([sql]) => String(sql).includes("status = 'published'")); + expect(markPublishedCall).toBeDefined(); + expect(markPublishedCall?.[1]).toEqual([durableRow.id]); + }); + + it("reschedules a durable event when required Dapr delivery fails", async () => { + const pool = buildPool([durableRow]); + mocks.getPool.mockReturnValue(pool); + mocks.kafkaProduce.mockResolvedValue(true); + mocks.daprPublish.mockRejectedValue(new Error("Dapr unavailable")); + + await expect(processDurableOutbox()).resolves.toBe(0); + + const markPublishedCall = pool.query.mock.calls.find(([sql]) => String(sql).includes("status = 'published'")); + const rescheduleCall = pool.query.mock.calls.find(([sql]) => String(sql).includes("next_attempt_at")); + expect(markPublishedCall).toBeUndefined(); + expect(rescheduleCall).toBeDefined(); + expect(rescheduleCall?.[1][1]).toBeGreaterThanOrEqual(5); + }); + + it("refuses to publish when PostgreSQL is unavailable instead of using memory", async () => { + mocks.getPool.mockReturnValue(null); + await expect(publishEvent("breach.created", "42", "breach_incident", { orgId: 7 })).rejects.toThrow( + /PostgreSQL pool is unavailable/ + ); + expect(mocks.kafkaProduce).not.toHaveBeenCalled(); + }); +}); diff --git a/server/eventBus.ts b/server/eventBus.ts index 776f2d9c..92688f72 100644 --- a/server/eventBus.ts +++ b/server/eventBus.ts @@ -1,36 +1,14 @@ /** - * NDSEP Event Bus — Domain Event Publishing - * ============================================ - * Bridges domain mutations to Kafka topics for event-driven architecture. - * All tRPC mutations that change state should publish events through this bus. + * NDSEP durable domain event bus. * - * Topics: - * ndsep.breach.created — New breach incident reported - * ndsep.breach.updated — Breach status/severity changed - * ndsep.enforcement.created — New enforcement case opened - * ndsep.enforcement.updated — Enforcement action taken - * ndsep.compliance.changed — Organization compliance score changed - * ndsep.consent.changed — Consent record created/withdrawn - * ndsep.organization.changed — Organization data updated - * ndsep.alert.created — Security alert triggered - * ndsep.audit.logged — Audit trail entry created - * ndsep.transfer.requested — Cross-border data transfer request - * ndsep.penalty.issued — Financial penalty issued - * ndsep.dpia.completed — DPIA assessment completed - * ndsep.noc.anomaly — NOC anomaly detected - * ndsep.agent.remediation — AI agent took remediation action - * - * Features: - * - Graceful degradation when Kafka is unavailable (events logged, not lost) - * - Event schema validation - * - Retry queue for failed publishes - * - Metrics (published, failed, retried) + * Every event is first recorded in PostgreSQL. Kafka and Dapr are downstream + * transports only; a process restart cannot discard a persisted event. */ - +import { randomUUID } from "node:crypto"; import { kafkaProduce } from "./kafka"; import { daprPublish } from "./dapr"; import { logger } from "./logger"; -import { captureWarning } from "./errorMonitoring"; +import { getPool } from "./db"; export type EventType = | "breach.created" | "breach.updated" | "breach.resolved" @@ -46,26 +24,161 @@ export type EventType = | "noc.anomaly" | "noc.escalation" | "agent.diagnosis" | "agent.remediation" | "agent.prediction"; -interface DomainEvent { +export interface DomainEvent { type: EventType; aggregateId: string; aggregateType: string; payload: Record; userId?: number; timestamp: string; - correlationId?: string; + correlationId: string; +} + +interface OutboxRow { + id: string; + event_type: EventType; + topic: string; + aggregate_id: string; + aggregate_type: string; + payload: Record; + headers: Record; + user_id: number | null; + correlation_id: string; + attempts: number; } const TOPIC_PREFIX = "ndsep"; +const OUTBOX_BATCH_SIZE = 50; +const OUTBOX_LEASE_SECONDS = 60; +const OUTBOX_POLL_MS = 15_000; let published = 0; let failed = 0; let retried = 0; -const retryQueue: DomainEvent[] = []; -const MAX_RETRY_QUEUE = 1000; +let retryTimer: ReturnType | undefined; function topicForEvent(type: EventType): string { - const domain = type.split(".")[0]; - return `${TOPIC_PREFIX}.${domain}.${type.split(".")[1]}`; + const [domain, action] = type.split("."); + return `${TOPIC_PREFIX}.${domain}.${action}`; +} + +function assertEventInput(type: EventType, aggregateId: string, aggregateType: string): void { + if (!type || !aggregateId || !aggregateType) throw new Error("Domain event requires type, aggregateId, and aggregateType"); + if (aggregateId.length > 255 || aggregateType.length > 128) throw new Error("Domain event aggregate identifiers exceed durable outbox limits"); +} + +function requirePool() { + const pool = getPool(); + if (!pool) throw new Error("PostgreSQL pool is unavailable; refusing non-durable domain event publication"); + return pool; +} + +async function enqueueEvent(event: DomainEvent): Promise { + const pool = requirePool(); + const topic = topicForEvent(event.type); + const headers = { + "event-type": event.type, + "aggregate-type": event.aggregateType, + "correlation-id": event.correlationId, + }; + await pool.query( + `INSERT INTO domain_event_outbox + (id, event_type, topic, aggregate_id, aggregate_type, payload, headers, user_id, correlation_id) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9)`, + [randomUUID(), event.type, topic, event.aggregateId, event.aggregateType, JSON.stringify(event), JSON.stringify(headers), event.userId ?? null, event.correlationId] + ); +} + +async function claimDueEvents(): Promise { + const pool = requirePool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const rows = await client.query( + `WITH due AS ( + SELECT id + FROM domain_event_outbox + WHERE (status = 'pending' AND next_attempt_at <= NOW()) + OR (status = 'publishing' AND lease_expires_at <= NOW()) + ORDER BY created_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE domain_event_outbox AS outbox + SET status = 'publishing', + attempts = outbox.attempts + 1, + lease_expires_at = NOW() + ($2 * INTERVAL '1 second'), + updated_at = NOW() + FROM due + WHERE outbox.id = due.id + RETURNING outbox.id, outbox.event_type, outbox.topic, outbox.aggregate_id, + outbox.aggregate_type, outbox.payload, outbox.headers, outbox.user_id, + outbox.correlation_id, outbox.attempts`, + [OUTBOX_BATCH_SIZE, OUTBOX_LEASE_SECONDS] + ); + await client.query("COMMIT"); + return rows.rows; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +function retryDelaySeconds(attempts: number): number { + return Math.min(3600, Math.max(5, 2 ** Math.min(attempts, 10))); +} + +async function markPublished(id: string): Promise { + await requirePool().query( + `UPDATE domain_event_outbox + SET status = 'published', published_at = NOW(), lease_expires_at = NULL, last_error = NULL, updated_at = NOW() + WHERE id = $1`, + [id] + ); +} + +async function reschedule(id: string, attempts: number, error: unknown): Promise { + const message = error instanceof Error ? error.message.slice(0, 1000) : String(error).slice(0, 1000); + await requirePool().query( + `UPDATE domain_event_outbox + SET status = 'pending', + next_attempt_at = NOW() + ($2 * INTERVAL '1 second'), + lease_expires_at = NULL, + last_error = $3, + updated_at = NOW() + WHERE id = $1`, + [id, retryDelaySeconds(attempts), message] + ); +} + +async function publishOutboxRow(row: OutboxRow): Promise { + const event = row.payload as unknown as DomainEvent; + try { + const kafkaOk = await kafkaProduce(row.topic, row.aggregate_id, event as unknown as Record, row.headers); + if (!kafkaOk) throw new Error("Kafka publish failed"); + // Dapr is a required downstream delivery. Any rejection keeps the event in + // PostgreSQL for retry rather than claiming a Kafka-only publication. + await daprPublish(row.topic, event as unknown as Record); + await markPublished(row.id); + published += 1; + if (row.attempts > 1) retried += 1; + return true; + } catch (error) { + failed += 1; + await reschedule(row.id, row.attempts, error); + logger.warn({ err: error, correlationId: row.correlation_id, attempts: row.attempts }, "[EventBus] durable outbox event rescheduled"); + return false; + } +} + +export async function processDurableOutbox(): Promise { + const rows = await claimDueEvents(); + let processed = 0; + for (const row of rows) { + if (await publishOutboxRow(row)) processed += 1; + } + return processed; } export async function publishEvent( @@ -76,6 +189,7 @@ export async function publishEvent( userId?: number, correlationId?: string, ): Promise { + assertEventInput(type, aggregateId, aggregateType); const event: DomainEvent = { type, aggregateId, @@ -83,115 +197,67 @@ export async function publishEvent( payload, userId, timestamp: new Date().toISOString(), - correlationId: correlationId ?? `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + correlationId: correlationId ?? randomUUID(), }; + await enqueueEvent(event); + // Make one immediate delivery attempt. Any failure remains in PostgreSQL. + await processDurableOutbox(); + return true; +} - const topic = topicForEvent(type); - - // Dual-publish: Kafka (primary) + Dapr (secondary, fire-and-forget) - const [kafkaOk] = await Promise.all([ - kafkaProduce(topic, aggregateId, event as unknown as Record, { - "event-type": type, - "aggregate-type": aggregateType, - "correlation-id": event.correlationId!, - }), - daprPublish(topic, event as unknown as Record).catch(() => false), - ]); - - if (kafkaOk) { - published++; - logger.debug({ type, aggregateId, topic }, "[EventBus] Published"); - return true; - } - - // Kafka unavailable — queue for retry - failed++; - if (retryQueue.length < MAX_RETRY_QUEUE) { - retryQueue.push(event); - } else { - captureWarning("Event retry queue full — dropping event", "event-bus", { type, aggregateId }); - } - logger.debug({ type, aggregateId }, "[EventBus] Queued for retry (Kafka unavailable)"); - return false; +export function startDurableOutbox(): void { + if (retryTimer) return; + retryTimer = setInterval(() => { + processDurableOutbox().catch((error) => logger.error({ err: error }, "[EventBus] durable outbox poll failed")); + }, OUTBOX_POLL_MS); + retryTimer.unref(); } -// Retry failed events periodically -async function processRetryQueue(): Promise { - if (retryQueue.length === 0) return 0; +export function stopDurableOutbox(): void { + if (retryTimer) clearInterval(retryTimer); + retryTimer = undefined; +} - let processed = 0; - const batch = retryQueue.splice(0, 50); // Process 50 at a time - - for (const event of batch) { - const topic = topicForEvent(event.type); - const ok = await kafkaProduce(topic, event.aggregateId, event as unknown as Record); - if (ok) { - processed++; - retried++; - } else { - // Put back in queue - if (retryQueue.length < MAX_RETRY_QUEUE) { - retryQueue.push(event); - } - break; // Kafka still down, stop retrying +export async function getEventBusMetrics() { + const pool = getPool(); + let pending = 0; + let publishing = 0; + if (pool) { + const result = await pool.query<{ status: "pending" | "publishing" | "published"; count: string }>( + `SELECT status, COUNT(*)::text AS count FROM domain_event_outbox WHERE status IN ('pending', 'publishing') GROUP BY status` + ); + for (const row of result.rows) { + if (row.status === "pending") pending = Number(row.count); + if (row.status === "publishing") publishing = Number(row.count); } } - - if (processed > 0) { - logger.info({ processed, remaining: retryQueue.length }, "[EventBus] Retry queue processed"); - } - return processed; + return { published, failed, retried, pending, publishing, persistence: "postgresql" as const }; } -// Start retry loop -setInterval(processRetryQueue, 15_000); - -// ─── Convenience Publishers ────────────────────────────────────────────────── - export async function publishBreachCreated(breachId: number, orgId: number, severity: string, userId?: number) { return publishEvent("breach.created", String(breachId), "breach_incident", { orgId, severity }, userId); } - export async function publishBreachUpdated(breachId: number, status: string, userId?: number) { return publishEvent("breach.updated", String(breachId), "breach_incident", { status }, userId); } - export async function publishEnforcementCreated(caseId: number, orgId: number, userId?: number) { return publishEvent("enforcement.created", String(caseId), "enforcement_case", { orgId }, userId); } - export async function publishComplianceChanged(orgId: number, oldScore: number, newScore: number) { return publishEvent("compliance.changed", String(orgId), "organization", { oldScore, newScore }); } - export async function publishConsentChanged(consentId: number, orgId: number, action: "created" | "withdrawn" | "expired") { return publishEvent(`consent.${action}`, String(consentId), "consent_record", { orgId }); } - export async function publishAlertCreated(alertId: number, orgId: number, severity: string) { return publishEvent("alert.created", String(alertId), "security_alert", { orgId, severity }); } - export async function publishPenaltyIssued(penaltyId: number, orgId: number, amount: number) { return publishEvent("penalty.issued", String(penaltyId), "financial_penalty", { orgId, amount }); } - export async function publishNocAnomaly(anomalyId: string, service: string, zScore: number) { return publishEvent("noc.anomaly", anomalyId, "noc_anomaly", { service, zScore }); } - export async function publishAgentRemediation(actionId: string, confidence: number, autoExecuted: boolean) { return publishEvent("agent.remediation", actionId, "agent_action", { confidence, autoExecuted }); } - -// ─── Metrics ───────────────────────────────────────────────────────────────── - -export function getEventBusMetrics() { - return { - published, - failed, - retried, - retryQueueSize: retryQueue.length, - maxRetryQueue: MAX_RETRY_QUEUE, - }; -} diff --git a/server/featureFlags/index.ts b/server/featureFlags/index.ts index 1773bc05..661ba73d 100644 --- a/server/featureFlags/index.ts +++ b/server/featureFlags/index.ts @@ -27,49 +27,37 @@ export type FeatureFlag = { updatedAt: Date; }; -// ── Schema ────────────────────────────────────────────────────────────────── - -const FLAGS_DDL = ` -CREATE TABLE IF NOT EXISTS feature_flags ( - key TEXT PRIMARY KEY, - enabled BOOLEAN NOT NULL DEFAULT false, - rollout_percentage INTEGER NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100), - target_orgs INTEGER[] DEFAULT '{}', - target_roles TEXT[] DEFAULT '{}', - environment TEXT[] DEFAULT '{production,staging,development}', - description TEXT DEFAULT '', - strategy VARCHAR(32) NOT NULL DEFAULT 'off', - parameters JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- The initial migration establishes the key-based contract. These additive columns --- preserve the existing strategy evaluator for installations upgraded in place. -ALTER TABLE feature_flags ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) NOT NULL DEFAULT 'off'; -ALTER TABLE feature_flags ADD COLUMN IF NOT EXISTS parameters JSONB NOT NULL DEFAULT '{}'; - -CREATE TABLE IF NOT EXISTS feature_flag_audit ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - flag_name VARCHAR(128) NOT NULL, - action VARCHAR(16) NOT NULL, - old_value JSONB, - new_value JSONB, - changed_by INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -`; +// ── Migration readiness ───────────────────────────────────────────────────── + +const REQUIRED_FEATURE_FLAG_COLUMNS = ["key", "enabled", "strategy", "parameters"] as const; + +async function assertFeatureFlagSchema(): Promise { + const db = await getDb(); + if (!db) throw new Error("Feature flag database is unavailable"); + + const result = await db.execute(sql` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'feature_flags' + AND column_name IN ('key', 'enabled', 'strategy', 'parameters') + `); + const found = new Set(result.rows.map((row) => String((row as { column_name: string }).column_name))); + const missing = REQUIRED_FEATURE_FLAG_COLUMNS.filter((column) => !found.has(column)); + if (missing.length > 0) { + throw new Error(`Feature flag migration 0044 is incomplete; missing columns: ${missing.join(", ")}`); + } +} export async function initFeatureFlags(): Promise { - const db = (await getDb())!; - try { - await db.execute(sql.raw(FLAGS_DDL)); - await seedDefaultFlags(); - logger.info("Feature flags initialized"); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - logger.warn({ err: msg }, "Feature flags init (tables may already exist)"); + if (process.env.NODE_ENV === "test") { + logger.info("Feature flag migration verification skipped in explicit test mode"); + return; } + + await assertFeatureFlagSchema(); + await seedDefaultFlags(); + logger.info("Feature flags initialized from migration-owned schema"); } async function seedDefaultFlags(): Promise { @@ -105,6 +93,18 @@ async function seedDefaultFlags(): Promise { // ── Flag Evaluation ───────────────────────────────────────────────────────── +function stableRolloutBucket( + flagName: string, + context?: { orgId?: number; sector?: string; userId?: number }, +): number { + const subject = context?.orgId ?? context?.userId ?? context?.sector ?? "anonymous"; + let hash = 0; + for (const character of `${flagName}:${subject}`) { + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + } + return hash % 100; +} + export async function isEnabled( flagName: string, context?: { orgId?: number; sector?: string; userId?: number }, @@ -126,8 +126,7 @@ export async function isEnabled( return false; case "percentage": { const pct = Number(row.parameters.percentage) || 0; - const hash = context?.orgId ?? context?.userId ?? Math.random() * 100; - return (hash % 100) < pct; + return stableRolloutBucket(flagName, context) < pct; } case "org_list": { const orgIds = (row.parameters.org_ids as number[]) ?? []; @@ -139,8 +138,7 @@ export async function isEnabled( } case "gradual": { const current = Number(row.parameters.current_percentage) || 0; - const hash = context?.orgId ?? Math.random() * 100; - return (hash % 100) < current; + return stableRolloutBucket(flagName, context) < current; } default: return row.enabled; diff --git a/server/financialIntegrationHardening.test.ts b/server/financialIntegrationHardening.test.ts new file mode 100644 index 00000000..4b22f956 --- /dev/null +++ b/server/financialIntegrationHardening.test.ts @@ -0,0 +1,51 @@ +import { createHmac } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { __test__ as mojaloop } from "./mojaloopCallback"; +import { KafkaDeliveryError, kafkaProduceRequired, checkKafkaHealth } from "./kafka"; +import { __test__ as tigerbeetle, createTigerBeetleTransaction } from "./tigerbeetle"; + +describe("financial integration hardening", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.MOJALOOP_CALLBACK_HMAC_SECRET; + }); + + it("accepts only a fresh HMAC over the exact Mojaloop callback bytes", () => { + process.env.MOJALOOP_CALLBACK_HMAC_SECRET = "a".repeat(32); + const raw = Buffer.from('{"transferId":"transfer-123","transferState":"COMMITTED"}', "utf8"); + const timestamp = String(Date.now()); + const input = Buffer.concat([Buffer.from(`ndsep-mojaloop-callback-v1.${timestamp}.`), raw]); + const signature = createHmac("sha256", process.env.MOJALOOP_CALLBACK_HMAC_SECRET).update(input).digest("hex"); + const req = { + get: (name: string) => name === "x-ndsep-mojaloop-timestamp" ? timestamp : name === "x-ndsep-mojaloop-signature" ? signature : undefined, + rawMojaloopCallbackBody: raw, + }; + expect(mojaloop.verifyCallback(req as any).ok).toBe(true); + (req as any).rawMojaloopCallbackBody = Buffer.from('{"transferId":"transfer-123","transferState":"ABORTED"}', "utf8"); + expect(mojaloop.verifyCallback(req as any).ok).toBe(false); + }); + + it("allows idempotent redelivery but never reverses a terminal Mojaloop state", () => { + expect(mojaloop.legalTransition("pending", "completed")).toBe(true); + expect(mojaloop.legalTransition("completed", "completed")).toBe(true); + expect(mojaloop.legalTransition("completed", "failed")).toBe(false); + expect(mojaloop.legalTransition("failed", "completed")).toBe(false); + }); + + it("creates a stable TigerBeetle idempotency key and rejects invalid money", () => { + const base = { orgId: "org-1", penaltyId: "penalty-1", amount: 12.5, type: "penalty" as const }; + expect(tigerbeetle.idempotencyKey(base)).toEqual(tigerbeetle.idempotencyKey({ ...base })); + expect(() => tigerbeetle.validateTransaction({ ...base, amount: 0 })).toThrow("positive"); + }); + + it("requires a durable TigerBeetle acknowledgement identifier pair", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ transaction_id: "only-one" }), { status: 200 }))); + await expect(createTigerBeetleTransaction({ orgId: "org-1", penaltyId: "p-1", amount: 5, type: "penalty" })).rejects.toThrow("missing durable transaction identifiers"); + }); + + it("throws for Kafka required delivery when the broker health check cannot establish delivery", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("broker unavailable"))); + await checkKafkaHealth(); + await expect(kafkaProduceRequired("ndsep.audit.events", "key-1", { event: "audit.action" })).rejects.toBeInstanceOf(KafkaDeliveryError); + }); +}); diff --git a/server/kafka.ts b/server/kafka.ts index ad5375f5..67650a48 100644 --- a/server/kafka.ts +++ b/server/kafka.ts @@ -1,282 +1,187 @@ +import { randomUUID } from "node:crypto"; import { logger } from "./logger"; + /** - * NDSEP Kafka Integration Module (Node.js) - * ========================================== - * Provides a lightweight HTTP client for Kafka REST Proxy and direct - * Kafka producer/consumer via kafkajs. - * - * Features: - * - Produce messages to Kafka topics - * - Consume messages from Kafka topics (polling) - * - Topic management (create, list, describe) - * - Graceful degradation when Kafka is unreachable - * - Metrics: produced, consumed, errors + * Kafka REST producer integration. * - * Environment variables: - * KAFKA_REST_URL - Kafka REST Proxy URL (default: http://localhost:8082) - * KAFKA_BOOTSTRAP_SERVERS - Kafka broker list (default: localhost:9092) - * KAFKA_ENABLED - "true" | "false" (default: "true") - * KAFKA_CLIENT_ID - Kafka client ID (default: ndsep-server) + * Regulatory and financial callers must use `kafkaProduceRequired`: it throws + * when delivery cannot be acknowledged. Boolean helpers remain for explicitly + * non-critical telemetry only. Production uses an HTTPS REST proxy with a + * dedicated bearer credential; local HTTP defaults are confined to non-prod. */ - +const IS_PRODUCTION = process.env.NODE_ENV === "production"; const KAFKA_REST_URL = process.env.KAFKA_REST_URL ?? "http://localhost:8082"; const KAFKA_BOOTSTRAP = process.env.KAFKA_BOOTSTRAP_SERVERS ?? "localhost:9092"; -const KAFKA_ENABLED = (process.env.KAFKA_ENABLED ?? "true") === "true"; +const KAFKA_ENABLED = (process.env.KAFKA_ENABLED ?? (IS_PRODUCTION ? "false" : "true")) === "true"; const KAFKA_CLIENT_ID = process.env.KAFKA_CLIENT_ID ?? "ndsep-server"; +const KAFKA_REST_TOKEN = process.env.KAFKA_REST_TOKEN; let kafkaConnected = false; let produced = 0; let consumed = 0; let errors = 0; -// ─── Health Check ───────────────────────────────────────────────────────────── +export class KafkaDeliveryError extends Error { + constructor(message: string) { super(message); this.name = "KafkaDeliveryError"; } +} + +function assertKafkaConfiguration(): void { + if (!KAFKA_ENABLED) throw new KafkaDeliveryError("Kafka is disabled"); + if (IS_PRODUCTION && !KAFKA_REST_URL.startsWith("https://")) throw new KafkaDeliveryError("Kafka REST endpoint must use HTTPS in production"); + if (IS_PRODUCTION && (!KAFKA_REST_TOKEN || KAFKA_REST_TOKEN.length < 32)) throw new KafkaDeliveryError("Kafka REST credential is not configured securely"); +} + +function assertTopic(topic: string): void { + if (!/^[a-z0-9._-]{3,249}$/i.test(topic) || topic.includes("..")) throw new KafkaDeliveryError("Invalid Kafka topic"); +} + +function kafkaHeaders(): Record { + return { + "Content-Type": "application/vnd.kafka.json.v2+json", + Accept: "application/vnd.kafka.v2+json", + "X-NDSEP-Client-Id": KAFKA_CLIENT_ID, + ...(KAFKA_REST_TOKEN ? { Authorization: `Bearer ${KAFKA_REST_TOKEN}` } : {}), + }; +} async function checkKafkaHealth(): Promise { if (!KAFKA_ENABLED) return false; try { - const res = await fetch(`${KAFKA_REST_URL}/brokers`, { - signal: AbortSignal.timeout(3000), - }); + assertKafkaConfiguration(); + const res = await fetch(`${KAFKA_REST_URL}/brokers`, { headers: kafkaHeaders(), signal: AbortSignal.timeout(3_000) }); const ok = res.ok; - if (ok && !kafkaConnected) logger.info(`[Kafka] Connected via REST Proxy at ${KAFKA_REST_URL}`); - if (!ok && kafkaConnected) logger.warn(`[Kafka] REST Proxy unhealthy`); + if (ok && !kafkaConnected) logger.info({ endpoint: KAFKA_REST_URL }, "[Kafka] Connected via REST proxy"); + if (!ok && kafkaConnected) logger.error({ status: res.status }, "[Kafka] REST proxy unhealthy"); kafkaConnected = ok; return ok; - } catch { - if (kafkaConnected) logger.warn(`[Kafka] REST Proxy unreachable — degrading gracefully`); + } catch (error) { + errors++; + if (kafkaConnected) logger.error({ err: error instanceof Error ? error.message : String(error) }, "[Kafka] REST proxy unavailable or untrusted"); kafkaConnected = false; return false; } } if (KAFKA_ENABLED) { - checkKafkaHealth().catch(() => { - logger.warn(`[Kafka] Could not connect — event streaming disabled (graceful degradation)`); - }); - setInterval(checkKafkaHealth, 30_000); + checkKafkaHealth().catch(() => undefined); + setInterval(() => { checkKafkaHealth().catch(() => undefined); }, 30_000).unref(); } -// ─── Produce ────────────────────────────────────────────────────────────────── - export async function kafkaProduce( topic: string, key: string | null, value: Record, - headers?: Record + headers?: Record, ): Promise { - if (!KAFKA_ENABLED || !kafkaConnected) return false; try { + assertKafkaConfiguration(); + assertTopic(topic); + if (!kafkaConnected) return false; + const correlationId = typeof value.event_id === "string" ? value.event_id : randomUUID(); const records = { records: [{ - key: key ?? undefined, - value, - headers: headers ? Object.entries(headers).map(([k, v]) => ({ [k]: v })) : undefined, + key: key ?? correlationId, + value: { ...value, event_id: correlationId, emitted_at: new Date().toISOString() }, + headers: { + "ndsep-correlation-id": correlationId, + ...(headers ?? {}), + }, }], }; const res = await fetch(`${KAFKA_REST_URL}/topics/${encodeURIComponent(topic)}`, { - method: "POST", - headers: { - "Content-Type": "application/vnd.kafka.json.v2+json", - Accept: "application/vnd.kafka.v2+json", - }, - body: JSON.stringify(records), - signal: AbortSignal.timeout(5000), + method: "POST", headers: kafkaHeaders(), body: JSON.stringify(records), signal: AbortSignal.timeout(5_000), }); - if (res.ok) { produced++; return true; } - errors++; - return false; - } catch { + if (!res.ok) { + errors++; + logger.error({ topic, status: res.status, correlationId }, "[Kafka] Produce request rejected"); + return false; + } + produced++; + return true; + } catch (error) { errors++; + logger.error({ topic, err: error instanceof Error ? error.message : String(error) }, "[Kafka] Produce failed"); return false; } } -// ─── Produce Batch ──────────────────────────────────────────────────────────── - -export async function kafkaProduceBatch( +export async function kafkaProduceRequired( topic: string, - messages: Array<{ key?: string; value: Record }> -): Promise { - if (!KAFKA_ENABLED || !kafkaConnected) return false; - try { - const records = { - records: messages.map(m => ({ - key: m.key ?? undefined, - value: m.value, - })), - }; - const res = await fetch(`${KAFKA_REST_URL}/topics/${encodeURIComponent(topic)}`, { - method: "POST", - headers: { - "Content-Type": "application/vnd.kafka.json.v2+json", - Accept: "application/vnd.kafka.v2+json", - }, - body: JSON.stringify(records), - signal: AbortSignal.timeout(10_000), - }); - if (res.ok) { produced += messages.length; return true; } - errors++; - return false; - } catch { - errors++; - return false; - } + key: string | null, + value: Record, + headers?: Record, +): Promise { + const delivered = await kafkaProduce(topic, key, value, headers); + if (!delivered) throw new KafkaDeliveryError(`Kafka delivery was not acknowledged for topic ${topic}`); } -// ─── Topic Management ───────────────────────────────────────────────────────── - -export async function kafkaListTopics(): Promise { - if (!KAFKA_ENABLED || !kafkaConnected) return []; +export async function kafkaProduceBatch(topic: string, messages: Array<{ key?: string; value: Record }>): Promise { try { - const res = await fetch(`${KAFKA_REST_URL}/topics`, { - signal: AbortSignal.timeout(5000), + assertKafkaConfiguration(); + assertTopic(topic); + if (!kafkaConnected || messages.length === 0 || messages.length > 500) return false; + const records = messages.map((message) => { + const correlationId = typeof message.value.event_id === "string" ? message.value.event_id : randomUUID(); + return { key: message.key ?? correlationId, value: { ...message.value, event_id: correlationId, emitted_at: new Date().toISOString() }, headers: { "ndsep-correlation-id": correlationId } }; }); - if (!res.ok) return []; - return await res.json() as string[]; - } catch { - errors++; - return []; - } -} - -export async function kafkaDescribeTopic(topic: string): Promise | null> { - if (!KAFKA_ENABLED || !kafkaConnected) return null; - try { const res = await fetch(`${KAFKA_REST_URL}/topics/${encodeURIComponent(topic)}`, { - signal: AbortSignal.timeout(5000), + method: "POST", headers: kafkaHeaders(), body: JSON.stringify({ records }), signal: AbortSignal.timeout(10_000), }); - if (!res.ok) return null; - return await res.json() as Record; - } catch { + if (!res.ok) { errors++; return false; } + produced += messages.length; + return true; + } catch (error) { errors++; - return null; - } -} - -// ─── Consumer Group ─────────────────────────────────────────────────────────── - -export async function kafkaCreateConsumer( - groupId: string, - instanceId: string, - autoOffsetReset: "earliest" | "latest" = "latest" -): Promise { - if (!KAFKA_ENABLED || !kafkaConnected) return null; - try { - const body = { - name: instanceId, - format: "json", - "auto.offset.reset": autoOffsetReset, - "auto.commit.enable": "true", - }; - const res = await fetch( - `${KAFKA_REST_URL}/consumers/${encodeURIComponent(groupId)}`, - { - method: "POST", - headers: { "Content-Type": "application/vnd.kafka.v2+json" }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5000), - } - ); - if (!res.ok) return null; - const data = await res.json() as { base_uri: string }; - return data.base_uri; - } catch { - errors++; - return null; + logger.error({ topic, err: error instanceof Error ? error.message : String(error) }, "[Kafka] Batch produce failed"); + return false; } } -export async function kafkaSubscribe( - consumerBaseUri: string, - topics: string[] -): Promise { - if (!KAFKA_ENABLED || !kafkaConnected) return false; +export async function kafkaListTopics(): Promise { + if (!await checkKafkaHealth()) return []; try { - const res = await fetch(`${consumerBaseUri}/subscription`, { - method: "POST", - headers: { "Content-Type": "application/vnd.kafka.v2+json" }, - body: JSON.stringify({ topics }), - signal: AbortSignal.timeout(5000), - }); - return res.ok; - } catch { - errors++; - return false; - } + const res = await fetch(`${KAFKA_REST_URL}/topics`, { headers: kafkaHeaders(), signal: AbortSignal.timeout(5_000) }); + return res.ok ? await res.json() as string[] : []; + } catch { errors++; return []; } } -export async function kafkaPoll( - consumerBaseUri: string, - maxBytes = 300000 -): Promise> { - if (!KAFKA_ENABLED || !kafkaConnected) return []; +export async function kafkaDescribeTopic(topic: string): Promise | null> { + if (!await checkKafkaHealth()) return null; try { - const res = await fetch(`${consumerBaseUri}/records?max_bytes=${maxBytes}`, { - headers: { Accept: "application/vnd.kafka.json.v2+json" }, - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) return []; - const records = await res.json() as Array<{ topic: string; key: string; value: unknown; partition: number; offset: number }>; - consumed += records.length; - return records; - } catch { - errors++; - return []; - } + assertTopic(topic); + const res = await fetch(`${KAFKA_REST_URL}/topics/${encodeURIComponent(topic)}`, { headers: kafkaHeaders(), signal: AbortSignal.timeout(5_000) }); + return res.ok ? await res.json() as Record : null; + } catch { errors++; return null; } } -// ─── Metrics ────────────────────────────────────────────────────────────────── - export function kafkaMetrics() { - return { - connected: kafkaConnected, - enabled: KAFKA_ENABLED, - restUrl: KAFKA_REST_URL, - bootstrap: KAFKA_BOOTSTRAP, - clientId: KAFKA_CLIENT_ID, - produced, - consumed, - errors, - }; + return { connected: kafkaConnected, enabled: KAFKA_ENABLED, restUrl: KAFKA_REST_URL, bootstrap: KAFKA_BOOTSTRAP, clientId: KAFKA_CLIENT_ID, ssl: KAFKA_REST_URL.startsWith("https://"), saslEnabled: Boolean(KAFKA_REST_TOKEN), produced, consumed, errors }; } export { kafkaConnected, checkKafkaHealth }; -// ── Backward-compatible publish helpers (used by routers.ts) ────────────────── - +// Backward-compatible event helpers retain non-blocking semantics for UI activity. +// Compliance-critical paths must use kafkaProduceRequired together with a DB outbox. export async function publishPenaltyIssued(data: { penaltyId: string | number; orgId: string | number; amount: number; currency: string; reason: string; issuedBy: string }): Promise { - await kafkaProduce("ndsep.penalties.issued", String(data.penaltyId), { event: "penalty.issued", ...data }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); + await kafkaProduce("ndsep.penalties.issued", String(data.penaltyId), { event: "penalty.issued", ...data }); } export async function publishEnforcementCaseOpened(data: { caseId: string | number; orgId: string | number; caseType: string; severity: string; openedBy: string }): Promise { - await kafkaProduce("ndsep.enforcement.cases", String(data.caseId), { event: "case.opened", ...data }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); + await kafkaProduce("ndsep.enforcement.cases", String(data.caseId), { event: "case.opened", ...data }); } export async function publishCitizenRightsRequest(data: { requestId: string | number; citizenId: string; requestType: string; status: string; orgId: string | number }): Promise { - await kafkaProduce("ndsep.citizen.rights", String(data.requestId), { event: "rights.request.updated", ...data }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); + await kafkaProduce("ndsep.citizen.rights", String(data.requestId), { event: "rights.request.updated", ...data }); } export async function publishComplianceViolation(data: { violationId: string | number; orgId: string | number; violationType: string; severity: string }): Promise { - await kafkaProduce("ndsep.compliance.violations", String(data.violationId), { event: "violation.detected", ...data }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); + await kafkaProduce("ndsep.compliance.violations", String(data.violationId), { event: "violation.detected", ...data }); } export async function publishAuditEvent(data: { userId: string; action: string; resource: string; resourceId: string | number; orgId?: string | number }): Promise { - await kafkaProduce("ndsep.audit.events", String(data.resourceId), { event: "audit.action", ...data }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); -} -export function isKafkaConnected(): boolean { - return kafkaConnected; -} -export function getKafkaProducerStatus(): { connected: boolean; enabled: boolean; brokers: string[]; clientId: string; ssl: boolean; saslEnabled: boolean; produced: number; errors: number } { - return { connected: kafkaConnected, enabled: KAFKA_ENABLED, brokers: [KAFKA_BOOTSTRAP], clientId: KAFKA_CLIENT_ID, ssl: false, saslEnabled: false, produced, errors }; + await kafkaProduce("ndsep.audit.events", String(data.resourceId), { event: "audit.action", ...data }); } +export function isKafkaConnected(): boolean { return kafkaConnected; } +export function getKafkaProducerStatus() { return kafkaMetrics(); } export async function kafkaSmokeTest(): Promise<{ ok: boolean; topic: string; message: string; latencyMs: number }> { - const t0 = Date.now(); - try { - const ok = await kafkaProduce("ndsep.smoke.test", `smoke-${Date.now()}`, { event: "smoke.test", ts: new Date().toISOString() }); - return { ok, topic: "ndsep.smoke.test", message: ok ? "Smoke test message produced" : "Kafka not connected (graceful degradation)", latencyMs: Date.now() - t0 }; - } catch (e: unknown) { - return { ok: false, topic: "ndsep.smoke.test", message: e instanceof Error ? e.message : "Unknown error", latencyMs: Date.now() - t0 }; - } -} - -export async function disconnectKafka(): Promise { - // No-op for REST Proxy based implementation — connections are stateless HTTP - kafkaConnected = false; + const start = Date.now(); + const ok = await kafkaProduce("ndsep.smoke.test", `smoke-${Date.now()}`, { event: "smoke.test" }); + return { ok, topic: "ndsep.smoke.test", message: ok ? "Smoke test message produced" : "Kafka delivery not acknowledged", latencyMs: Date.now() - start }; } - +export async function disconnectKafka(): Promise { kafkaConnected = false; } diff --git a/server/kafkaConsumer.ts b/server/kafkaConsumer.ts index 1c36fe16..70b09772 100644 --- a/server/kafkaConsumer.ts +++ b/server/kafkaConsumer.ts @@ -15,6 +15,18 @@ import { opensearchIndex, lakehouseIngest } from "./middlewareExtensions"; const KAFKA_BROKERS = process.env.KAFKA_BROKERS || "localhost:9092"; const KAFKA_ENABLED = process.env.KAFKA_ENABLED !== "false"; const CONSUMER_GROUP = "ndsep-api-consumer"; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; +const KAFKA_SASL_USERNAME = process.env.KAFKA_SASL_USERNAME; +const KAFKA_SASL_PASSWORD = process.env.KAFKA_SASL_PASSWORD; + +function assertConsumerConfiguration(): void { + if (IS_PRODUCTION && (!KAFKA_SASL_USERNAME || !KAFKA_SASL_PASSWORD)) { + throw new Error("Kafka SASL credentials are required in production"); + } + if (IS_PRODUCTION && KAFKA_BROKERS.split(",").some((broker) => /(^|[/:])localhost([/:]|$)/i.test(broker.trim()))) { + throw new Error("Kafka localhost broker is not permitted in production"); + } +} // Event-driven handlers for domain-specific reactions async function handleBreachEvent(event: KafkaEvent): Promise { @@ -135,16 +147,23 @@ async function processMessage(topic: string, value: string): Promise { promises.push(route.handler(event)); } - await Promise.allSettled(promises); + // A consumer offset may advance only after every required projection and handler succeeds. + await Promise.all(promises); metrics.messagesProcessed++; } catch (err) { metrics.errors++; - logger.warn({ err: err instanceof Error ? err.message : String(err), topic }, "[KafkaConsumer] Message processing failed"); - // Route failed messages to DLQ for exponential backoff retry + const message = err instanceof Error ? err.message : String(err); + logger.error({ err: message, topic }, "[KafkaConsumer] Message processing failed; refusing offset acknowledgement"); try { const { addToDLQ } = await import("./productionGaps"); - addToDLQ(topic, `msg-${Date.now()}`, value, err instanceof Error ? err.message : String(err)); - } catch { /* DLQ module unavailable — message lost */ } + const eventId = (() => { try { return String((JSON.parse(value) as KafkaEvent).metadata?.event_id ?? (JSON.parse(value) as KafkaEvent).entity_id ?? "unknown"); } catch { return "malformed"; } })(); + await Promise.resolve(addToDLQ(topic, `kafka-${eventId}`, value, message)); + } catch (dlqError) { + logger.error({ err: dlqError instanceof Error ? dlqError.message : String(dlqError), topic }, "[KafkaConsumer] DLQ failure; message remains unacknowledged"); + } + // KafkaJS will not commit the offset when eachMessage rejects. A non-production REST + // poller also receives this failure for explicit operational visibility. + throw err instanceof Error ? err : new Error(message); } } @@ -161,11 +180,15 @@ export async function startKafkaConsumer(): Promise { logger.info({ brokers: KAFKA_BROKERS, group: CONSUMER_GROUP }, "[KafkaConsumer] Starting"); try { - // Try KafkaJS (optional dependency) + assertConsumerConfiguration(); + // Production requires authenticated KafkaJS transport. The REST fallback is limited to + // explicitly non-production developer environments because it cannot prove commits. const { Kafka } = await import("kafkajs"); const kafka = new Kafka({ clientId: "ndsep-api", brokers: KAFKA_BROKERS.split(","), + ssl: IS_PRODUCTION ? true : undefined, + sasl: IS_PRODUCTION ? { mechanism: "plain", username: KAFKA_SASL_USERNAME!, password: KAFKA_SASL_PASSWORD! } : undefined, retry: { retries: 5, initialRetryTime: 1000, maxRetryTime: 30000 }, connectionTimeout: 10000, }); @@ -189,9 +212,15 @@ export async function startKafkaConsumer(): Promise { kafkaClient = consumer; logger.info({ topics }, "[KafkaConsumer] Subscribed and running"); } catch (err) { - logger.info({ err: err instanceof Error ? err.message : String(err) }, "[KafkaConsumer] KafkaJS not available, using HTTP polling fallback"); + metrics.errors++; + if (IS_PRODUCTION) { + metrics.running = false; + logger.error({ err: err instanceof Error ? err.message : String(err) }, "[KafkaConsumer] Production Kafka startup failed; REST polling fallback is forbidden"); + throw err; + } + logger.warn({ err: err instanceof Error ? err.message : String(err) }, "[KafkaConsumer] Non-production KafkaJS unavailable; using development REST polling fallback"); - // HTTP polling fallback via Kafka REST proxy (if available at kafka:8082) + // HTTP polling fallback is developer-only and never establishes a production delivery claim. const REST_URL = process.env.KAFKA_REST_URL || `http://${KAFKA_BROKERS.split(",")[0].replace(":9092", ":8082")}`; let running = true; @@ -208,8 +237,9 @@ export async function startKafkaConsumer(): Promise { await processMessage(record.topic, JSON.stringify(record.value)); } } - } catch { - // Silent — REST proxy may not be available + } catch (error) { + metrics.errors++; + logger.warn({ err: error instanceof Error ? error.message : String(error) }, "[KafkaConsumer] Development REST poll failed"); } await new Promise(r => setTimeout(r, 5000)); // Poll every 5s } diff --git a/server/marketplace/index.ts b/server/marketplace/index.ts index d1b81c26..86809126 100644 --- a/server/marketplace/index.ts +++ b/server/marketplace/index.ts @@ -37,73 +37,24 @@ export type WebhookEvent = | "certificate.issued" | "certificate.revoked"; -// ── Schema ────────────────────────────────────────────────────────────────── +// ── Migration readiness ───────────────────────────────────────────────────── -const MARKETPLACE_DDL = ` -CREATE TABLE IF NOT EXISTS api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - org_id INTEGER NOT NULL, - name VARCHAR(128) NOT NULL, - key_hash VARCHAR(64) NOT NULL UNIQUE, - key_prefix VARCHAR(12) NOT NULL, - scopes TEXT[] NOT NULL DEFAULT '{read}', - rate_limit_rpm INTEGER NOT NULL DEFAULT 60, - expires_at TIMESTAMPTZ, - last_used_at TIMESTAMPTZ, - total_requests INTEGER DEFAULT 0, - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS webhook_subscriptions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - org_id INTEGER NOT NULL, - url TEXT NOT NULL, - events TEXT[] NOT NULL, - secret VARCHAR(64) NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'active', - failure_count INTEGER DEFAULT 0, - last_triggered_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS marketplace_plugins ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(128) NOT NULL UNIQUE, - description TEXT, - author VARCHAR(128), - version VARCHAR(16) NOT NULL DEFAULT '1.0.0', - sector VARCHAR(64), - category VARCHAR(64), - install_count INTEGER DEFAULT 0, - status VARCHAR(16) NOT NULL DEFAULT 'published', - manifest JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS api_usage_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - api_key_id UUID REFERENCES api_keys(id), - endpoint VARCHAR(256) NOT NULL, - method VARCHAR(8) NOT NULL, - status_code INTEGER, - response_time_ms INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_usage_log (created_at DESC); -CREATE INDEX IF NOT EXISTS idx_api_usage_log_api_key_id_fk ON api_usage_log (api_key_id); -`; +const REQUIRED_MARKETPLACE_TABLES = ["api_keys", "api_usage_log", "marketplace_plugins", "webhook_subscriptions"] as const; export async function initMarketplace(): Promise { - const db = (await getDb())!; - try { - await db.execute(sql.raw(MARKETPLACE_DDL)); - await seedPlugins(); - logger.info("API Marketplace initialized"); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - logger.warn({ err: msg }, "Marketplace init (tables may already exist)"); - } + if (process.env.NODE_ENV === "test") return; + const db = await getDb(); + if (!db) throw new Error("Marketplace database is unavailable"); + const result = await db.execute(sql` + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('api_keys', 'api_usage_log', 'marketplace_plugins', 'webhook_subscriptions') + `); + const found = new Set(result.rows.map((row) => String((row as { table_name: string }).table_name))); + const missing = REQUIRED_MARKETPLACE_TABLES.filter((table) => !found.has(table)); + if (missing.length) throw new Error(`Marketplace migration 0029 is incomplete; missing tables: ${missing.join(", ")}`); + await seedPlugins(); + logger.info("API Marketplace initialized from migration-owned schema"); } async function seedPlugins(): Promise { @@ -185,11 +136,11 @@ export async function triggerWebhooks(event: WebhookEvent, payload: unknown): Pr const db = (await getDb())!; const subs = await db.execute(sql` SELECT id, url, secret FROM webhook_subscriptions - WHERE ${event} = ANY(events) AND status = 'active' + WHERE ${event} = ANY(events) AND active = true `); let delivered = 0; - for (const row of subs.rows as { id: string; url: string; secret: string }[]) { + for (const row of subs.rows as { id: number; url: string; secret: string }[]) { const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() }); const signature = crypto.createHmac("sha256", row.secret).update(body).digest("hex"); @@ -208,16 +159,16 @@ export async function triggerWebhooks(event: WebhookEvent, payload: unknown): Pr if (res.ok) { delivered++; await db.execute(sql` - UPDATE webhook_subscriptions SET last_triggered_at = NOW(), failure_count = 0 WHERE id = ${row.id}::uuid + UPDATE webhook_subscriptions SET last_delivery_at = NOW(), failure_count = 0 WHERE id = ${row.id} `); } else { await db.execute(sql` - UPDATE webhook_subscriptions SET failure_count = failure_count + 1 WHERE id = ${row.id}::uuid + UPDATE webhook_subscriptions SET failure_count = failure_count + 1 WHERE id = ${row.id} `); } } catch { await db.execute(sql` - UPDATE webhook_subscriptions SET failure_count = failure_count + 1 WHERE id = ${row.id}::uuid + UPDATE webhook_subscriptions SET failure_count = failure_count + 1 WHERE id = ${row.id} `); } } diff --git a/server/middleware/opensearch.hardening.test.ts b/server/middleware/opensearch.hardening.test.ts new file mode 100644 index 00000000..d6a1970d --- /dev/null +++ b/server/middleware/opensearch.hardening.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +const originalEnabled = process.env.OPENSEARCH_ENABLED; + +vi.mock("../logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.stubGlobal("fetch", fetchMock); + +describe("middleware OpenSearch hardening", () => { + beforeEach(() => { + vi.resetModules(); + fetchMock.mockReset(); + process.env.OPENSEARCH_ENABLED = "false"; + }); + + afterEach(() => { + if (originalEnabled === undefined) { + delete process.env.OPENSEARCH_ENABLED; + } else { + process.env.OPENSEARCH_ENABLED = originalEnabled; + } + vi.clearAllMocks(); + }); + + it("does not issue network requests when explicitly disabled", async () => { + const { bulkIndex, checkOpenSearchHealth, indexDocument, search } = await import("./opensearch"); + + await expect(search("ndsep-organizations", "sensitive search term")).resolves.toEqual({ + hits: [], + total: 0, + took: 0, + }); + await expect(indexDocument("ndsep-organizations", "org-1", { name: "Organisation" })).resolves.toBe(false); + await expect( + bulkIndex("ndsep-organizations", [{ id: "org-1", doc: { name: "Organisation" } }]), + ).resolves.toBe(0); + await expect(checkOpenSearchHealth()).resolves.toEqual({ + healthy: false, + url: "http://localhost:9200", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects an unallow-listed index before issuing a request", async () => { + const { search } = await import("./opensearch"); + + await expect(search("arbitrary-index", "query")).rejects.toThrow("not allow-listed"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/middleware/opensearch.ts b/server/middleware/opensearch.ts index 6ea74af4..e1341e27 100644 --- a/server/middleware/opensearch.ts +++ b/server/middleware/opensearch.ts @@ -6,10 +6,13 @@ */ import { logger } from "../logger"; +import { assertOpenSearchIndex, getOpenSearchConfig } from "../opensearchConfig"; -const OS_URL = process.env.OPENSEARCH_URL ?? "http://localhost:9200"; -const OS_USER = process.env.OPENSEARCH_USER ?? "admin"; -const OS_PASS = process.env.OPENSEARCH_PASS ?? "admin"; +const openSearchConfig = getOpenSearchConfig(); +const OS_URL = openSearchConfig.url; +const OS_USER = openSearchConfig.username; +const OS_PASS = openSearchConfig.password; +const OS_ENABLED = openSearchConfig.enabled; // Index definitions export const INDICES = { @@ -20,8 +23,11 @@ export const INDICES = { ASSETS: "ndsep-assets", } as const; -function authHeader(): string { - return "Basic " + Buffer.from(`${OS_USER}:${OS_PASS}`).toString("base64"); +const MIDDLEWARE_INDEX_NAMES = Object.values(INDICES); + +function authHeaders(): Record { + if (!OS_USER || !OS_PASS) return {}; + return { Authorization: "Basic " + Buffer.from(`${OS_USER}:${OS_PASS}`).toString("base64") }; } // ── Search Operations ──────────────────────────────────────────────────────── @@ -33,6 +39,8 @@ export interface SearchResult { } export async function search(index: string, query: string, options?: { from?: number; size?: number; filters?: Record }): Promise { + assertOpenSearchIndex(index, MIDDLEWARE_INDEX_NAMES); + if (!OS_ENABLED) return { hits: [], total: 0, took: 0 }; try { const body: Record = { query: { @@ -54,8 +62,9 @@ export async function search(index: string, query: string, options?: { from?: nu const res = await fetch(`${OS_URL}/${index}/_search`, { method: "POST", - headers: { "Content-Type": "application/json", Authorization: authHeader() }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`OpenSearch error: ${res.status}`); @@ -71,7 +80,7 @@ export async function search(index: string, query: string, options?: { from?: nu took: data.took ?? 0, }; } catch (err) { - logger.warn({ err, index, query }, "[OpenSearch] Search failed"); + logger.warn({ err, index, queryLength: query.length }, "[OpenSearch] Search failed"); return { hits: [], total: 0, took: 0 }; } } @@ -79,11 +88,14 @@ export async function search(index: string, query: string, options?: { from?: nu // ── Indexing Operations ────────────────────────────────────────────────────── export async function indexDocument(index: string, id: string, document: unknown): Promise { + assertOpenSearchIndex(index, MIDDLEWARE_INDEX_NAMES); + if (!OS_ENABLED) return false; try { const res = await fetch(`${OS_URL}/${index}/_doc/${id}`, { method: "PUT", - headers: { "Content-Type": "application/json", Authorization: authHeader() }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(document), + signal: AbortSignal.timeout(10_000), }); return res.ok; } catch (err) { @@ -93,6 +105,8 @@ export async function indexDocument(index: string, id: string, document: unknown } export async function bulkIndex(index: string, documents: Array<{ id: string; doc: unknown }>): Promise { + assertOpenSearchIndex(index, MIDDLEWARE_INDEX_NAMES); + if (!OS_ENABLED) return 0; try { const bulkBody = documents.flatMap(d => [ JSON.stringify({ index: { _index: index, _id: d.id } }), @@ -101,12 +115,16 @@ export async function bulkIndex(index: string, documents: Array<{ id: string; do const res = await fetch(`${OS_URL}/_bulk`, { method: "POST", - headers: { "Content-Type": "application/x-ndjson", Authorization: authHeader() }, + headers: { "Content-Type": "application/x-ndjson", ...authHeaders() }, body: bulkBody, + signal: AbortSignal.timeout(30_000), }); - const data = await res.json(); - return documents.length - (data.errors ? data.items.filter((i: any) => i.index?.error).length : 0); + const data = await res.json() as { errors?: boolean; items?: Array<{ index?: { error?: unknown } }> }; + if (!res.ok || data.errors) { + throw new Error(`OpenSearch bulk index failed with HTTP ${res.status}`); + } + return documents.length; } catch (err) { logger.warn({ err, index }, "[OpenSearch] Bulk index failed"); return 0; @@ -116,9 +134,11 @@ export async function bulkIndex(index: string, documents: Array<{ id: string; do // ── Health Check ───────────────────────────────────────────────────────────── export async function checkOpenSearchHealth(): Promise<{ healthy: boolean; url: string; clusterStatus?: string }> { + if (!OS_ENABLED) return { healthy: false, url: OS_URL }; try { const res = await fetch(`${OS_URL}/_cluster/health`, { - headers: { Authorization: authHeader() }, + headers: authHeaders(), + signal: AbortSignal.timeout(10_000), }); if (!res.ok) return { healthy: false, url: OS_URL }; const data = await res.json(); diff --git a/server/middlewareExtensions.tigerbeetle.test.ts b/server/middlewareExtensions.tigerbeetle.test.ts new file mode 100644 index 00000000..fed90501 --- /dev/null +++ b/server/middlewareExtensions.tigerbeetle.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createTigerBeetleTransaction } = vi.hoisted(() => ({ + createTigerBeetleTransaction: vi.fn(), +})); + +vi.mock("./tigerbeetle", () => ({ + createTigerBeetleTransaction, +})); + +import { tigerbeetleTransfer } from "./middlewareExtensions"; + +describe("TigerBeetle middleware bridge", () => { + beforeEach(() => { + createTigerBeetleTransaction.mockReset(); + createTigerBeetleTransaction.mockResolvedValue({ success: true }); + }); + + it("submits an explicit, account-bound transfer through the validated ledger client", async () => { + await tigerbeetleTransfer({ + debitAccountId: "bank-000001", + creditAccountId: "bank-000002", + amount: 100.25, + currency: "NGN", + reference: "NIP-001", + transferType: "NIP_TRANSFER", + }); + + expect(createTigerBeetleTransaction).toHaveBeenCalledTimes(1); + expect(createTigerBeetleTransaction).toHaveBeenCalledWith({ + orgId: "bank-000001", + penaltyId: "NIP-001", + amount: 100.25, + currency: "NGN", + type: "transfer", + debitAccountId: "bank-000001", + creditAccountId: "bank-000002", + description: "NIP_TRANSFER", + }); + }); + + it("propagates a durable-ledger rejection instead of reporting a local success", async () => { + createTigerBeetleTransaction.mockRejectedValueOnce(new Error("ledger unavailable")); + + await expect(tigerbeetleTransfer({ + debitAccountId: "bank-000001", + creditAccountId: "bank-000002", + amount: 1, + currency: "NGN", + reference: "NIP-002", + })).rejects.toThrow("ledger unavailable"); + }); +}); diff --git a/server/middlewareExtensions.ts b/server/middlewareExtensions.ts index ce932712..b135ef72 100644 --- a/server/middlewareExtensions.ts +++ b/server/middlewareExtensions.ts @@ -5,6 +5,7 @@ */ import { permifyCheck as checkPermifyPermission } from "./permify"; +import { createTigerBeetleTransaction } from "./tigerbeetle"; // ─── Service URLs ──────────────────────────────────────────────────────────── @@ -12,7 +13,6 @@ const DAPR_BRIDGE_URL = process.env.DAPR_BRIDGE_URL || "http://localhost:8150"; const FLUVIO_RELAY_URL = process.env.FLUVIO_RELAY_URL || "http://localhost:8151"; const MOJALOOP_ADAPTER_URL = process.env.MOJALOOP_ADAPTER_URL || "http://localhost:8152"; const APISIX_MANAGER_URL = process.env.APISIX_MANAGER_URL || "http://localhost:8153"; -const TIGERBEETLE_LEDGER_URL = process.env.TIGERBEETLE_LEDGER_URL || "http://localhost:8160"; const OPENSEARCH_INDEXER_URL = process.env.OPENSEARCH_INDEXER_URL || "http://localhost:8161"; const KEYCLOAK_VALIDATOR_URL = process.env.KEYCLOAK_VALIDATOR_URL || "http://localhost:8162"; const LAKEHOUSE_INGEST_URL = process.env.LAKEHOUSE_INGEST_URL || "http://localhost:8163"; @@ -23,7 +23,26 @@ const DAPR_STATE_URL = process.env.DAPR_STATE_URL || "http://localhost:8167"; // ─── Shared fetch helper ───────────────────────────────────────────────────── +const IS_PRODUCTION = process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; + +function trustedEndpoint(url: string): URL { + let endpoint: URL; + try { + endpoint = new URL(url); + } catch { + throw new Error("Required integration endpoint is not a valid absolute URL"); + } + if (IS_PRODUCTION && endpoint.protocol !== "https:") { + throw new Error(`Required production integration endpoint must use HTTPS: ${endpoint.origin}`); + } + if (endpoint.username || endpoint.password) { + throw new Error(`Required integration endpoint must not contain inline credentials: ${endpoint.origin}`); + } + return endpoint; +} + async function postJSON(url: string, body: object): Promise { + const endpoint = trustedEndpoint(url); let response: Response; try { response = await fetch(url, { @@ -33,11 +52,11 @@ async function postJSON(url: string, body: object): Promise { signal: AbortSignal.timeout(3000), }); } catch (err) { - throw new Error(`Required integration POST ${url} is unavailable: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); + throw new Error(`Required integration POST ${endpoint.origin} is unavailable: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } if (!response.ok) { const detail = await response.text().catch(() => ""); - throw new Error(`Required integration POST ${url} failed with HTTP ${response.status}: ${detail}`); + throw new Error(`Required integration POST ${endpoint.origin} failed with HTTP ${response.status}: ${detail}`); } } @@ -55,16 +74,18 @@ export async function daprStateSet(key: string, value: unknown): Promise { /** Get a value from the Dapr state store */ export async function daprStateGet(key: string): Promise { + const url = `${DAPR_STATE_URL}/state/get`; + const endpoint = trustedEndpoint(url); let resp: Response; try { - resp = await fetch(`${DAPR_STATE_URL}/state/get`, { + resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), signal: AbortSignal.timeout(2000), }); } catch (err) { - throw new Error(`Dapr state lookup for ${key} is unavailable: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); + throw new Error(`Dapr state lookup for ${key} at ${endpoint.origin} is unavailable: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } if (!resp.ok) throw new Error(`Dapr state lookup for ${key} failed with HTTP ${resp.status}`); const data = await resp.json() as { value: unknown }; @@ -75,7 +96,7 @@ export async function daprStateGet(key: string): Promise { /** Relay an event to Fluvio (high-throughput streaming) */ export async function fluvioPublish(topic: string, event: object): Promise { - await postJSON(`${FLUVIO_RELAY_URL}/publish`, { topic, event }); + await postJSON(`${FLUVIO_RELAY_URL}/publish`, { topic, payload: event }); // Also push to consumer for routing await postJSON(`${FLUVIO_CONSUMER_URL}/publish`, { topic: `ndsep.${topic.replace(/-/g, ".")}`, @@ -92,7 +113,9 @@ export async function opensearchIndex(index: string, doc: object): Promise /** Search OpenSearch (returns results or empty array on error) */ export async function opensearchSearch(index: string, params: object): Promise { - const resp = await fetch(`${OPENSEARCH_QUERY_URL}/search`, { + const url = `${OPENSEARCH_QUERY_URL}/search`; + trustedEndpoint(url); + const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index, ...params }), @@ -105,7 +128,9 @@ export async function opensearchSearch(index: string, params: object): Promise { - const resp = await fetch(`${OPENSEARCH_QUERY_URL}/search/global`, { + const url = `${OPENSEARCH_QUERY_URL}/search/global`; + trustedEndpoint(url); + const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ q, sectors }), @@ -129,7 +154,7 @@ export async function lakehouseIngest(table: string, records: object[]): Promise // ─── TigerBeetle ───────────────────────────────────────────────────────────── -/** Record a financial transaction in TigerBeetle */ +/** Record a financial transaction through the TigerBeetle-backed Go ledger proxy. */ export async function tigerbeetleTransfer(params: { debitAccountId: string; creditAccountId: string; @@ -138,13 +163,15 @@ export async function tigerbeetleTransfer(params: { reference: string; transferType?: string; }): Promise { - await postJSON(`${TIGERBEETLE_LEDGER_URL}/transfers`, { - debit_account_id: params.debitAccountId, - credit_account_id: params.creditAccountId, + await createTigerBeetleTransaction({ + orgId: params.debitAccountId, + penaltyId: params.reference, amount: params.amount, currency: params.currency, - user_data: params.reference, - transfer_type: params.transferType || "REGULATORY_FINE", + type: "transfer", + debitAccountId: params.debitAccountId, + creditAccountId: params.creditAccountId, + description: params.transferType ?? "REGULATORY_TRANSFER", }); } @@ -178,13 +205,18 @@ export async function keycloakValidate(token: string, requiredRoles?: string[]): username?: string; }> { try { - const resp = await fetch(`${KEYCLOAK_VALIDATOR_URL}/validate`, { + const url = `${KEYCLOAK_VALIDATOR_URL}/validate`; + trustedEndpoint(url); + const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, required_roles: requiredRoles }), signal: AbortSignal.timeout(3000), }); - return await resp.json() as { valid: boolean; roles: string[]; sub?: string; username?: string }; + if (!resp.ok) return { valid: false, roles: [] }; + const result = await resp.json() as { valid?: boolean; roles?: string[]; sub?: string; username?: string }; + if (!result.valid || !Array.isArray(result.roles)) return { valid: false, roles: [] }; + return { valid: true, roles: result.roles, sub: result.sub, username: result.username }; } catch (err) { return { valid: false, roles: [] }; } @@ -223,6 +255,7 @@ export async function apisixRegisterRoute(params: { upstreamUrl: string; plugins?: object; }): Promise { + trustedEndpoint(params.upstreamUrl); await postJSON(`${APISIX_MANAGER_URL}/routes`, { route_id: params.routeId, uri: params.uri, diff --git a/server/middlewareExtensionsHardening.test.ts b/server/middlewareExtensionsHardening.test.ts new file mode 100644 index 00000000..aab85205 --- /dev/null +++ b/server/middlewareExtensionsHardening.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalNodeEnv = process.env.NODE_ENV; +const originalAppEnv = process.env.APP_ENV; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + if (originalAppEnv === undefined) delete process.env.APP_ENV; + else process.env.APP_ENV = originalAppEnv; +}); + +describe("required middleware integration hardening", () => { + it("uses the Fluvio relay payload contract instead of silently dropping the event body", async () => { + vi.stubEnv("NODE_ENV", "test"); + const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + const { fluvioPublish } = await import("./middlewareExtensions"); + + const event = { event_id: "evt-1", severity: "high" }; + await fluvioPublish("compliance-event", event); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(firstBody).toEqual({ topic: "compliance-event", payload: event }); + expect(firstBody).not.toHaveProperty("event"); + }); + + it("rejects a plaintext required integration endpoint before any production network call", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("APP_ENV", "production"); + vi.stubEnv("DAPR_BRIDGE_URL", "http://dapr.internal:8150"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const { daprPublish } = await import("./middlewareExtensions"); + + await expect(daprPublish("compliance-events", { event_id: "evt-2" })) + .rejects.toThrow("must use HTTPS"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("fails closed when Keycloak returns a non-success or malformed identity response", async () => { + vi.stubEnv("NODE_ENV", "test"); + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response("denied", { status: 401 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, roles: "admin" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const { keycloakValidate } = await import("./middlewareExtensions"); + + await expect(keycloakValidate("test-token")).resolves.toEqual({ valid: false, roles: [] }); + await expect(keycloakValidate("test-token")).resolves.toEqual({ valid: false, roles: [] }); + }); +}); diff --git a/server/middlewareHelpers.ts b/server/middlewareHelpers.ts index ec9efec2..e6ea8b68 100644 --- a/server/middlewareHelpers.ts +++ b/server/middlewareHelpers.ts @@ -83,7 +83,7 @@ export function broadcastEvent(event: string, data: Record): vo // ─── TigerBeetle ───────────────────────────────────────────────────────────── export async function recordFinancialTransaction( type: TbTransactionType, - amountUsd: number, + amount: number, orgId: string, penaltyId: string, description?: string, @@ -91,7 +91,7 @@ export async function recordFinancialTransaction( const { tigerbeetleResilience } = await import("./resilience"); await tigerbeetleResilience(() => createTigerBeetleTransaction({ type, - amountUsd, + amount, orgId, penaltyId, description, diff --git a/server/mojaloop.receiptIntegrity.test.ts b/server/mojaloop.receiptIntegrity.test.ts new file mode 100644 index 00000000..f1f055e1 --- /dev/null +++ b/server/mojaloop.receiptIntegrity.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +vi.mock("./logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); +vi.mock("./errorMonitoring", () => ({ captureError: vi.fn() })); + +import { createSettlement, executeTransfer } from "./mojaloop"; + +afterEach(() => { + fetchMock.mockReset(); +}); + +describe("Mojaloop receipt integrity", () => { + it("reports submission acceptance rather than fabricating a committed transfer state", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 202 })); + + await expect( + executeTransfer("transfer-0001", "ndsep", "recipient", { amount: "42.50", currency: "NGN" }) + ).resolves.toEqual({ ok: true, transferId: "transfer-0001", state: "ACCEPTED" }); + }); + + it("does not report settlement success without a durable hub settlement identifier", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 202 })); + + await expect(createSettlement("model-0001", "period close")).resolves.toEqual({ + ok: false, + settlementId: undefined, + data: undefined, + }); + }); + + it("preserves a hub-provided transfer state without treating it as callback-confirmed", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ transferState: "PENDING" }), { status: 202 })); + + await expect( + executeTransfer("transfer-0002", "ndsep", "recipient", { amount: "1.00", currency: "NGN" }) + ).resolves.toEqual({ ok: true, transferId: "transfer-0002", state: "PENDING" }); + }); +}); diff --git a/server/mojaloop.ts b/server/mojaloop.ts index 2f0b5349..fb94657b 100644 --- a/server/mojaloop.ts +++ b/server/mojaloop.ts @@ -21,10 +21,20 @@ import { logger } from "./logger"; import { captureError } from "./errorMonitoring"; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; const MOJALOOP_URL = process.env.MOJALOOP_URL ?? "http://localhost:4002"; const MOJALOOP_ALS_URL = process.env.MOJALOOP_ALS_URL ?? "http://localhost:4001"; const MOJALOOP_FSPIOP_SRC = process.env.MOJALOOP_FSPIOP_SRC ?? "ndsep"; -const MOJALOOP_ENABLED = (process.env.MOJALOOP_ENABLED ?? "true") === "true"; +const MOJALOOP_ENABLED = (process.env.MOJALOOP_ENABLED ?? (IS_PRODUCTION ? "false" : "true")) === "true"; +const MOJALOOP_AUTH_TOKEN = process.env.MOJALOOP_AUTH_TOKEN; + +function trustedMojaloopConfiguration(): string | null { + if (!MOJALOOP_ENABLED) return "Mojaloop disabled"; + if (IS_PRODUCTION && (!MOJALOOP_URL.startsWith("https://") || !MOJALOOP_ALS_URL.startsWith("https://"))) return "Mojaloop endpoints must use HTTPS in production"; + if (IS_PRODUCTION && (!MOJALOOP_AUTH_TOKEN || MOJALOOP_AUTH_TOKEN.length < 32)) return "Mojaloop authentication token is not configured securely"; + if (!/^[A-Za-z0-9._-]{2,32}$/.test(MOJALOOP_FSPIOP_SRC)) return "Mojaloop FSPIOP source is invalid"; + return null; +} let connected = false; let transfers = 0; @@ -35,13 +45,15 @@ function fspiopHeaders(dest?: string): Record { "Content-Type": "application/vnd.interoperability.transfers+json;version=1.1", Accept: "application/vnd.interoperability.transfers+json;version=1.1", "FSPIOP-Source": MOJALOOP_FSPIOP_SRC, + ...(MOJALOOP_AUTH_TOKEN ? { Authorization: `Bearer ${MOJALOOP_AUTH_TOKEN}` } : {}), ...(dest ? { "FSPIOP-Destination": dest } : {}), Date: new Date().toUTCString(), }; } async function mojaRequest(method: string, url: string, body?: unknown, headers?: Record): Promise<{ ok: boolean; status: number; data: unknown }> { - if (!MOJALOOP_ENABLED) return { ok: false, status: 0, data: { error: "Mojaloop disabled" } }; + const configurationError = trustedMojaloopConfiguration(); + if (configurationError) return { ok: false, status: 0, data: { error: configurationError } }; try { const res = await fetch(url, { method, @@ -105,6 +117,11 @@ export async function lookupParty( // ─── Quote ─────────────────────────────────────────────────────────────────── +function validateAmount(amount: { amount: string; currency: string }): void { + if (!/^\d+(\.\d{1,2})?$/.test(amount.amount) || Number(amount.amount) <= 0) throw new Error("Mojaloop amount must be a positive decimal with at most two fractional digits"); + if (!/^[A-Z]{3}$/.test(amount.currency)) throw new Error("Mojaloop currency must be a three-letter ISO code"); +} + export async function createQuote( quoteId: string, payerFsp: string, @@ -112,6 +129,8 @@ export async function createQuote( amount: { amount: string; currency: string }, transactionType: string, ): Promise<{ ok: boolean; quoteId: string; data?: unknown }> { + validateAmount(amount); + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(quoteId)) throw new Error("Invalid Mojaloop quote ID"); const { ok, data } = await mojaRequest("POST", `${MOJALOOP_URL}/quotes`, { quoteId, transactionId: `txn-${quoteId}`, @@ -135,6 +154,8 @@ export async function executeTransfer( ilpPacket?: string, condition?: string, ): Promise<{ ok: boolean; transferId: string; state?: string }> { + validateAmount(amount); + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(transferId)) throw new Error("Invalid Mojaloop transfer ID"); const { ok, data } = await mojaRequest("POST", `${MOJALOOP_URL}/transfers`, { transferId, payerFsp, @@ -146,7 +167,10 @@ export async function executeTransfer( }); if (ok) transfers++; const d = data as Record; - return { ok, transferId, state: ok ? String(d.transferState ?? "COMMITTED") : undefined }; + const reportedState = typeof d.transferState === "string" ? d.transferState.trim() : ""; + // A successful HTTP submission proves only hub acceptance. Terminal settlement + // state is established by the authenticated callback path, never inferred here. + return { ok, transferId, state: ok ? (reportedState || "ACCEPTED") : undefined }; } // ─── Smoke Test ────────────────────────────────────────────────────────────── @@ -204,7 +228,9 @@ export async function createSettlement( settlementModel: settlementModelId, }); const d = data as Record; - return { ok, settlementId: ok ? String(d.id ?? "") : undefined, data: ok ? data : undefined }; + const settlementId = typeof d.id === "string" ? d.id.trim() : ""; + // A 2xx response without a durable hub identifier cannot be reconciled safely. + return { ok: ok && settlementId.length > 0, settlementId: settlementId || undefined, data: ok && settlementId ? data : undefined }; } export async function getSettlements(state?: string): Promise<{ ok: boolean; settlements: unknown[] }> { diff --git a/server/mojaloopCallback.ts b/server/mojaloopCallback.ts index 8add9e4c..7ef06a54 100644 --- a/server/mojaloopCallback.ts +++ b/server/mojaloopCallback.ts @@ -1,15 +1,21 @@ /** * Mojaloop Transfer Callback Handler - * Receives asynchronous transfer status updates from the Mojaloop hub. - * Updates financial_ledger status and emits domain events. + * + * The hub callback is a financial trust boundary. NDSEP accepts an update only + * when it has a shared callback-authentication secret, the raw received bytes + * validate, the request is fresh, its path/body transfer IDs agree, and the + * proposed state is a legal idempotent transition from the durable ledger state. */ import { Router } from "express"; import type { Request, Response } from "express"; -import { getPool } from "./db"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { logger } from "./logger"; +import { applyMojaloopPaymentCallback } from "./paymentCommandProcessor"; import { emitMutationEvent, EVENTS } from "./middlewareIntegration"; const router = Router(); +const CALLBACK_MAX_AGE_MS = 5 * 60_000; +const CALLBACK_CLOCK_SKEW_MS = 30_000; interface TransferCallback { transferId: string; @@ -23,67 +29,125 @@ interface TransferCallback { }; } +type LedgerStatus = "pending" | "completed" | "failed"; + +function safeEqualHex(expected: string, received: string): boolean { + if (!/^[a-f0-9]{64}$/i.test(received)) return false; + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(received, "hex"); + return a.length === b.length && timingSafeEqual(a, b); +} + +function verifyCallback(req: Request): { ok: true } | { ok: false; reason: string } { + const secret = process.env.MOJALOOP_CALLBACK_HMAC_SECRET; + const timestamp = req.get("x-ndsep-mojaloop-timestamp"); + const signature = req.get("x-ndsep-mojaloop-signature"); + const rawBody = (req as Request & { rawMojaloopCallbackBody?: Buffer }).rawMojaloopCallbackBody; + + if (!secret || secret.length < 32) return { ok: false, reason: "callback authentication is not configured" }; + if (!timestamp || !/^\d{13}$/.test(timestamp)) return { ok: false, reason: "missing or invalid callback timestamp" }; + if (!signature) return { ok: false, reason: "missing callback signature" }; + if (!rawBody) return { ok: false, reason: "raw callback body unavailable" }; + + const receivedAt = Date.now(); + const issuedAt = Number(timestamp); + if (!Number.isSafeInteger(issuedAt) || issuedAt > receivedAt + CALLBACK_CLOCK_SKEW_MS || receivedAt - issuedAt > CALLBACK_MAX_AGE_MS) { + return { ok: false, reason: "callback timestamp outside accepted window" }; + } + + // Versioned pre-authentication encoding binds timestamp and exact JSON bytes. + const signingInput = Buffer.concat([Buffer.from(`ndsep-mojaloop-callback-v1.${timestamp}.`, "utf8"), rawBody]); + const expected = createHmac("sha256", secret).update(signingInput).digest("hex"); + return safeEqualHex(expected, signature) ? { ok: true } : { ok: false, reason: "callback signature verification failed" }; +} + +function statusFor(state: TransferCallback["transferState"]): LedgerStatus { + return state === "COMMITTED" ? "completed" : state === "ABORTED" ? "failed" : "pending"; +} + +function legalTransition(current: string, requested: LedgerStatus): boolean { + if (current === requested) return true; // Safe idempotent redelivery. + if (current === "pending") return requested === "completed" || requested === "failed"; + return false; // A terminal state never transitions back or changes terminal outcome. +} + /** * PUT /api/mojaloop/transfers/:transferId - * Mojaloop sends this when a transfer completes or fails. + * + * Sender computes `HMAC-SHA-256(secret, utf8("ndsep-mojaloop-callback-v1." + + * timestamp + ".") || raw_body)` and sends lowercase hex in + * `X-NDSEP-Mojaloop-Signature`, with a 13-digit epoch-millisecond timestamp. */ router.put("/transfers/:transferId", async (req: Request, res: Response) => { const { transferId } = req.params; - const body = req.body as TransferCallback; - const state = body.transferState; + const body = req.body as Partial; + const verified = verifyCallback(req); + if (!verified.ok) { + logger.warn({ transferId, reason: verified.reason }, "[Mojaloop] Rejected unauthenticated or stale transfer callback"); + res.status(401).json({ error: "Callback authentication failed" }); + return; + } - logger.info({ transferId, state }, "[Mojaloop] Transfer callback received"); + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(transferId) || body.transferId !== transferId || !["COMMITTED", "ABORTED", "RESERVED"].includes(String(body.transferState))) { + logger.warn({ transferId }, "[Mojaloop] Rejected malformed or path/body-mismatched callback"); + res.status(400).json({ error: "Invalid transfer callback" }); + return; + } + + const state = body.transferState as TransferCallback["transferState"]; + const requestedStatus = statusFor(state); + const completedAt = body.completedTimestamp ? new Date(body.completedTimestamp) : new Date(); + if (Number.isNaN(completedAt.getTime())) { + res.status(400).json({ error: "Invalid completedTimestamp" }); + return; + } try { - const pool = getPool(); - if (!pool) { - res.status(503).json({ error: "Database unavailable" }); + const outcome = await applyMojaloopPaymentCallback(transferId, state, completedAt); + if (outcome === "not_found") { + res.status(404).json({ error: "Transfer not found" }); + return; + } + if (outcome === "illegal_transition") { + logger.error({ transferId, requestedStatus }, "[Mojaloop] Rejected illegal payment-command state transition"); + res.status(409).json({ error: "Illegal transfer state transition" }); return; } - const dbStatus = state === "COMMITTED" ? "completed" : state === "ABORTED" ? "failed" : "pending"; - - await pool.query( - `UPDATE financial_ledger - SET status = $1, completed_at = $2, mojaloop_fulfilment = $3, updated_at = NOW() - WHERE reference = $4`, - [dbStatus, body.completedTimestamp ?? new Date().toISOString(), body.fulfilment ?? null, transferId] - ); - - emitMutationEvent(EVENTS.ENFORCEMENT_PAYMENT, { - entity: "transfer", - transferId, - newStatus: dbStatus, - completedAt: body.completedTimestamp, - }); - + if (outcome === "updated") { + emitMutationEvent(EVENTS.ENFORCEMENT_PAYMENT, { + entity: "transfer", + transferId, + newStatus: requestedStatus, + completedAt: completedAt.toISOString(), + authenticated: true, + }); + } if (state === "ABORTED" && body.errorInformation) { - logger.warn({ transferId, error: body.errorInformation }, "[Mojaloop] Transfer aborted"); + logger.warn({ transferId, errorCode: body.errorInformation.errorCode }, "[Mojaloop] Authenticated transfer abort callback"); } - - res.status(200).json({ accepted: true, transferId, state: dbStatus }); + res.status(200).json({ accepted: true, idempotent: outcome === "idempotent", transferId, state: requestedStatus }); } catch (err) { - logger.error({ err, transferId }, "[Mojaloop] Callback processing failed"); + logger.error({ err, transferId }, "[Mojaloop] Authenticated callback processing failed"); res.status(500).json({ error: "Internal error" }); } }); -/** - * GET /api/mojaloop/transfers/:transferId/status - * Allows checking current transfer status from the DB. - */ router.get("/transfers/:transferId/status", async (req: Request, res: Response) => { const { transferId } = req.params; try { + const { getPool } = await import("./db"); const pool = getPool(); if (!pool) { res.status(503).json({ error: "Database unavailable" }); return; } const result = await pool.query( - `SELECT reference, status, amount, currency, completed_at FROM financial_ledger WHERE reference = $1`, - [transferId] + `SELECT payment_reference AS reference, status, amount, currency, completed_at, + tigerbeetle_transaction_id, mojaloop_reference + FROM payment_commands + WHERE payment_reference = $1 OR mojaloop_reference = $1`, + [transferId], ); - const rows = result.rows; - if (rows.length === 0) { res.status(404).json({ error: "Transfer not found" }); return; } - res.json(rows[0]); + if (result.rows.length === 0) { res.status(404).json({ error: "Transfer not found" }); return; } + res.json(result.rows[0]); } catch { res.status(500).json({ error: "Internal error" }); } @@ -91,5 +155,7 @@ router.get("/transfers/:transferId/status", async (req: Request, res: Response) export function registerMojaloopCallbacks(app: { use: (...args: any[]) => void }): void { app.use("/api/mojaloop", router); - logger.info("[Mojaloop] Callback handler registered (PUT /api/mojaloop/transfers/:id)"); + logger.info("[Mojaloop] Authenticated callback handler registered"); } + +export const __test__ = { legalTransition, statusFor, verifyCallback }; diff --git a/server/multiTenancy.postgres.integration.test.ts b/server/multiTenancy.postgres.integration.test.ts new file mode 100644 index 00000000..1a79671e --- /dev/null +++ b/server/multiTenancy.postgres.integration.test.ts @@ -0,0 +1,43 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const databaseUrl = process.env.MULTITENANCY_RLS_TEST_DATABASE_URL; + +function isDisposableLocalPostgresUrl(value: string): boolean { + const parsed = new URL(value); + const databaseName = parsed.pathname.replace(/^\//, "").toLowerCase(); + return ( + parsed.protocol === "postgresql:" && + ["127.0.0.1", "localhost"].includes(parsed.hostname) && + /(?:test|ci|synthetic|integration)/.test(databaseName) + ); +} + +const shouldRun = + typeof databaseUrl === "string" && isDisposableLocalPostgresUrl(databaseUrl); + +if (databaseUrl && !shouldRun) { + throw new Error( + "MULTITENANCY_RLS_TEST_DATABASE_URL must identify a localhost disposable test database" + ); +} + +describe.skipIf(!shouldRun)( + "multi-tenancy RLS root-migration PostgreSQL integration", + () => { + const originalDatabaseUrl = process.env.DATABASE_URL; + + beforeAll(() => { + process.env.DATABASE_URL = databaseUrl; + }); + + afterAll(() => { + if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalDatabaseUrl; + }); + + it("executes the read-only RLS migration-state verifier without ambiguous parameters", async () => { + const { enableRowLevelSecurity } = await import("./multiTenancy"); + await expect(enableRowLevelSecurity()).resolves.toBeUndefined(); + }); + } +); diff --git a/server/multiTenancy.ts b/server/multiTenancy.ts index d126f157..bbf4de2c 100644 --- a/server/multiTenancy.ts +++ b/server/multiTenancy.ts @@ -59,30 +59,19 @@ export async function enableRowLevelSecurity(): Promise { ); if (!exists.rows[0].exists) continue; - // Enable RLS - await pool.query(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`); - - // Create policy for org isolation const policyName = `${table}_org_isolation`; - await pool.query(` - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_policies WHERE tablename = '${table}' AND policyname = '${policyName}' - ) THEN - EXECUTE format( - 'CREATE POLICY %I ON %I FOR ALL USING (%I = current_setting(''app.current_org_id'', true)::int OR current_setting(''app.is_admin'', true) = ''true'')', - '${policyName}', '${table}', '${orgCol}' - ); - END IF; - END - $$; - `); + const state = await pool.query<{ rls_enabled: boolean; policy_exists: boolean }>( + `SELECT + COALESCE((SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass(format('public.%I', $1::text))), false) AS rls_enabled, + EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = $1::text AND policyname = $2::text) AS policy_exists`, + [table, policyName] + ); + if (!state.rows[0]?.rls_enabled || !state.rows[0]?.policy_exists) { + throw new Error(`RLS migration state is incomplete for ${table}.${orgCol}`); + } - logger.info({ table, orgCol }, "[MultiTenancy] RLS enabled on %s", table); + logger.info({ table, orgCol }, "[MultiTenancy] RLS migration verified"); } - } catch (err) { - logger.warn({ err }, "[MultiTenancy] RLS setup failed — application-level filtering still active"); } finally { await pool.end(); } @@ -96,10 +85,10 @@ export async function setTenantContext( client: { query: (sql: string, params?: unknown[]) => Promise }, tenant: TenantContext ): Promise { - if (tenant.dpcoOrgId) { - await client.query(`SET LOCAL app.current_org_id = '${tenant.dpcoOrgId}'`); + if (tenant.dpcoOrgId !== null) { + await client.query("SELECT set_config('app.current_org_id', $1, true)", [String(tenant.dpcoOrgId)]); } - await client.query(`SET LOCAL app.is_admin = '${tenant.isAdmin}'`); + await client.query("SELECT set_config('app.is_admin', $1, true)", [tenant.isAdmin ? "true" : "false"]); } /** diff --git a/server/opensearch.ts b/server/opensearch.ts index b056a987..c4ea906a 100644 --- a/server/opensearch.ts +++ b/server/opensearch.ts @@ -21,11 +21,13 @@ import { logger } from "./logger"; import { captureError } from "./errorMonitoring"; +import { assertOpenSearchIndex, getOpenSearchConfig } from "./opensearchConfig"; -const OPENSEARCH_URL = process.env.OPENSEARCH_URL ?? "http://localhost:9200"; -const OPENSEARCH_USERNAME = process.env.OPENSEARCH_USERNAME; -const OPENSEARCH_PASSWORD = process.env.OPENSEARCH_PASSWORD; -const OPENSEARCH_ENABLED = (process.env.OPENSEARCH_ENABLED ?? "true") === "true"; +const openSearchConfig = getOpenSearchConfig(); +const OPENSEARCH_URL = openSearchConfig.url; +const OPENSEARCH_USERNAME = openSearchConfig.username; +const OPENSEARCH_PASSWORD = openSearchConfig.password; +const OPENSEARCH_ENABLED = openSearchConfig.enabled; let connected = false; let indexed = 0; @@ -148,6 +150,8 @@ const NDSEP_INDICES = [ }, ]; +const NDSEP_INDEX_NAMES = NDSEP_INDICES.map((index) => index.name); + export async function ensureIndices(): Promise<{ created: string[]; existing: string[] }> { const created: string[] = []; const existing: string[] = []; @@ -175,6 +179,7 @@ export async function ensureIndices(): Promise<{ created: string[]; existing: st // ─── Document Operations ───────────────────────────────────────────────────── export async function indexDocument(index: string, id: string, doc: Record): Promise { + assertOpenSearchIndex(index, NDSEP_INDEX_NAMES); const { ok, status, data } = await osRequest("PUT", `/${index}/_doc/${id}`, doc); if (!ok) { throw new Error(`OpenSearch document index failed for ${index}/${id}: ${status || "connection unavailable"} ${JSON.stringify(data)}`); @@ -184,6 +189,7 @@ export async function indexDocument(index: string, id: string, doc: Record }>): Promise { + assertOpenSearchIndex(index, NDSEP_INDEX_NAMES); if (docs.length === 0) return 0; if (!OPENSEARCH_ENABLED) throw new Error("OpenSearch is disabled; bulk indexing cannot be acknowledged"); const lines: string[] = []; @@ -219,6 +225,7 @@ export async function search( query: Record, options?: { size?: number; from?: number; sort?: unknown; highlight?: unknown }, ): Promise<{ hits: unknown[]; total: number; took: number }> { + assertOpenSearchIndex(index, NDSEP_INDEX_NAMES); const body: Record = { query, size: options?.size ?? 20, diff --git a/server/opensearchConfig.test.ts b/server/opensearchConfig.test.ts new file mode 100644 index 00000000..002b0bb5 --- /dev/null +++ b/server/opensearchConfig.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { assertOpenSearchIndex, getOpenSearchConfig } from "./opensearchConfig"; + +describe("OpenSearch configuration hardening", () => { + it("keeps local development defaults available without injecting credentials", () => { + expect(getOpenSearchConfig({ NODE_ENV: "test" })).toEqual({ + enabled: true, + url: "http://localhost:9200", + username: undefined, + password: undefined, + }); + }); + + it("requires an explicit enabled state before production imports an OpenSearch client", () => { + expect(() => getOpenSearchConfig({ NODE_ENV: "production" })).toThrow( + "OPENSEARCH_ENABLED", + ); + }); + + it("rejects enabled production plaintext, inline credentials, and missing authentication", () => { + expect(() => + getOpenSearchConfig({ + NODE_ENV: "production", + OPENSEARCH_ENABLED: "true", + OPENSEARCH_URL: "http://opensearch.internal:9200", + OPENSEARCH_USERNAME: "ndsep-indexer", + OPENSEARCH_PASSWORD: "A-long-production-password-value", + }), + ).toThrow("https://"); + + expect(() => + getOpenSearchConfig({ + NODE_ENV: "production", + OPENSEARCH_ENABLED: "true", + OPENSEARCH_URL: "https://operator:secret@opensearch.example.internal", + OPENSEARCH_USERNAME: "ndsep-indexer", + OPENSEARCH_PASSWORD: "A-long-production-password-value", + }), + ).toThrow("inline credentials"); + + expect(() => + getOpenSearchConfig({ + NODE_ENV: "production", + OPENSEARCH_ENABLED: "true", + OPENSEARCH_URL: "https://opensearch.example.internal", + }), + ).toThrow("OPENSEARCH_USERNAME"); + }); + + it("accepts a non-placeholder HTTPS production endpoint with strong explicit credentials", () => { + expect( + getOpenSearchConfig({ + NODE_ENV: "production", + OPENSEARCH_ENABLED: "true", + OPENSEARCH_URL: "https://opensearch.ndsep.internal/", + OPENSEARCH_USERNAME: "ndsep-indexer", + OPENSEARCH_PASSWORD: "kT9qPx7WvL3mR8sN2cF6hJ4dZa1YbE5", + }), + ).toEqual({ + enabled: true, + url: "https://opensearch.ndsep.internal", + username: "ndsep-indexer", + password: "kT9qPx7WvL3mR8sN2cF6hJ4dZa1YbE5", + }); + }); + + it("allows an explicitly disabled production integration without requiring service credentials", () => { + expect( + getOpenSearchConfig({ + NODE_ENV: "production", + OPENSEARCH_ENABLED: "false", + }), + ).toEqual({ + enabled: false, + url: "http://localhost:9200", + username: undefined, + password: undefined, + }); + }); + + it("supports the legacy variable names only when they agree with canonical names", () => { + expect( + getOpenSearchConfig({ + NODE_ENV: "test", + OPENSEARCH_USER: "legacy-user", + OPENSEARCH_PASS: "legacy-password", + }), + ).toMatchObject({ + username: "legacy-user", + password: "legacy-password", + }); + + expect(() => + getOpenSearchConfig({ + NODE_ENV: "test", + OPENSEARCH_USERNAME: "canonical-user", + OPENSEARCH_USER: "legacy-user", + }), + ).toThrow("conflicting values"); + }); + + it("rejects indices outside each caller's explicit allow-list", () => { + expect(() => assertOpenSearchIndex("arbitrary-index", ["ndsep-audit-logs"])).toThrow( + "not allow-listed", + ); + expect(() => assertOpenSearchIndex("ndsep-audit-logs", ["ndsep-audit-logs"])).not.toThrow(); + }); +}); diff --git a/server/opensearchConfig.ts b/server/opensearchConfig.ts new file mode 100644 index 00000000..7e6dcfb5 --- /dev/null +++ b/server/opensearchConfig.ts @@ -0,0 +1,83 @@ +export interface OpenSearchConfig { + enabled: boolean; + url: string; + username?: string; + password?: string; +} + +type Environment = Record; + +const DEFAULT_DEVELOPMENT_URL = "http://localhost:9200"; +const PLACEHOLDER_PATTERN = /(?:change[_ -]?me|placeholder|example|default|replace[_ -]?me|<[^>]+>)/i; + +function isProduction(env: Environment): boolean { + return env.NODE_ENV === "production" || env.APP_ENV === "production"; +} + +function resolveCredential( + env: Environment, + canonicalName: "OPENSEARCH_USERNAME" | "OPENSEARCH_PASSWORD", + legacyName: "OPENSEARCH_USER" | "OPENSEARCH_PASS", +): string | undefined { + const canonical = env[canonicalName]; + const legacy = env[legacyName]; + if (canonical && legacy && canonical !== legacy) { + throw new Error(`${canonicalName} and ${legacyName} must not contain conflicting values`); + } + return canonical ?? legacy; +} + +function isUnsafeSecret(value: string): boolean { + return value.trim().length < 24 || PLACEHOLDER_PATTERN.test(value); +} + +export function getOpenSearchConfig(env: Environment = process.env): OpenSearchConfig { + const production = isProduction(env); + const configuredEnabled = env.OPENSEARCH_ENABLED; + if (configuredEnabled !== undefined && configuredEnabled !== "true" && configuredEnabled !== "false") { + throw new Error("OPENSEARCH_ENABLED must be exactly true or false when set"); + } + if (production && configuredEnabled === undefined) { + throw new Error("Production OpenSearch requires OPENSEARCH_ENABLED to be explicitly true or false"); + } + + const enabled = configuredEnabled === "true" || (!production && configuredEnabled !== "false"); + const url = env.OPENSEARCH_URL ?? DEFAULT_DEVELOPMENT_URL; + const username = resolveCredential(env, "OPENSEARCH_USERNAME", "OPENSEARCH_USER"); + const password = resolveCredential(env, "OPENSEARCH_PASSWORD", "OPENSEARCH_PASS"); + + if (!enabled) { + return { enabled, url, username, password }; + } + + let endpoint: URL; + try { + endpoint = new URL(url); + } catch { + throw new Error("OPENSEARCH_URL must be an absolute URL"); + } + + if (endpoint.username || endpoint.password) { + throw new Error("OPENSEARCH_URL must not contain inline credentials"); + } + + if (production) { + if (endpoint.protocol !== "https:") { + throw new Error("Enabled production OpenSearch requires an https:// OPENSEARCH_URL"); + } + if (!username || username.trim().length < 3 || PLACEHOLDER_PATTERN.test(username)) { + throw new Error("Enabled production OpenSearch requires a non-placeholder OPENSEARCH_USERNAME"); + } + if (!password || isUnsafeSecret(password)) { + throw new Error("Enabled production OpenSearch requires a non-placeholder OPENSEARCH_PASSWORD of at least 24 characters"); + } + } + + return { enabled, url: endpoint.toString().replace(/\/$/, ""), username, password }; +} + +export function assertOpenSearchIndex(index: string, allowedIndices: readonly string[]): void { + if (!allowedIndices.includes(index)) { + throw new Error(`OpenSearch index is not allow-listed: ${index}`); + } +} diff --git a/server/orchestration.integrationOutcome.test.ts b/server/orchestration.integrationOutcome.test.ts new file mode 100644 index 00000000..48de694f --- /dev/null +++ b/server/orchestration.integrationOutcome.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +vi.mock("./temporal", () => ({ + startWorkflow: vi.fn(), +})); + +vi.mock("./logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("./resilience", () => ({ + withResilience: async ( + _name: string, + operation: () => Promise + ): Promise => operation(), +})); + +vi.stubGlobal("fetch", fetchMock); + +const { j01_orgRegistered, j03_violationDetected } = await import( + "./orchestration" +); + +function jsonResponse(status: number, body: unknown = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("orchestration journey integration outcomes", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("reports a non-2xx required event publication as degraded instead of fulfilled", async () => { + fetchMock.mockImplementation((input: string | URL) => { + if (String(input).includes(":8160/events/publish")) { + return Promise.resolve( + jsonResponse(503, { error: "event bus unavailable" }) + ); + } + return Promise.resolve(jsonResponse(201)); + }); + + const result = await j01_orgRegistered({ + orgId: "org-1", + orgName: "Example Organisation", + sector: "finance", + contactEmail: "contact@ndsep.test", + submissionId: "submission-1", + }); + + expect(result).toEqual({ + journey: "J01", + eventBus: "degraded", + lakehouse: "ingested", + }); + }); + + it("reports a network-rejected required event publication as degraded", async () => { + fetchMock.mockImplementation((input: string | URL) => { + if (String(input).includes(":8160/events/publish")) { + return Promise.reject(new Error("connect ECONNREFUSED")); + } + return Promise.resolve(jsonResponse(201)); + }); + + const result = await j01_orgRegistered({ + orgId: "org-2", + orgName: "Example Organisation", + sector: "finance", + contactEmail: "contact@ndsep.test", + submissionId: "submission-2", + }); + + expect(result).toEqual({ + journey: "J01", + eventBus: "degraded", + lakehouse: "ingested", + }); + }); + + it("reports each J03 dependency independently using verified operation outcomes", async () => { + fetchMock.mockImplementation((input: string | URL) => { + const url = String(input); + if (url.includes(":8160/events/publish")) { + return Promise.resolve( + jsonResponse(503, { error: "event bus unavailable" }) + ); + } + if (url.includes(":8170/workflows/start")) { + return Promise.resolve(jsonResponse(202, { id: "workflow-1" })); + } + return Promise.resolve(jsonResponse(201)); + }); + + const result = await j03_violationDetected({ + violationId: "violation-1", + orgId: "org-3", + violationType: "residency", + severity: "high", + description: "A verified regression test event", + }); + + expect(result).toEqual({ + journey: "J03", + eventBus: "degraded", + workflow: "started", + lakehouse: "ingested", + }); + }); +}); diff --git a/server/orchestration.ts b/server/orchestration.ts index d84298c3..849c7585 100644 --- a/server/orchestration.ts +++ b/server/orchestration.ts @@ -89,6 +89,13 @@ interface OrchestrationResult { latencyMs?: number; } +function integrationOutcome( + result: PromiseSettledResult, + completedLabel: string, +): string { + return result.status === "fulfilled" && result.value.ok ? completedLabel : "degraded"; +} + async function callService( serviceUrl: string, path: string, @@ -157,7 +164,11 @@ export async function j01_orgRegistered(params: { journey_id: "J01", }), ]); - return { journey: "J01", eventBus: eventResult.status, lakehouse: lhResult.status }; + return { + journey: "J01", + eventBus: integrationOutcome(eventResult, "published"), + lakehouse: integrationOutcome(lhResult, "ingested"), + }; } export async function j02_complianceAssessed(params: { @@ -174,7 +185,11 @@ export async function j02_complianceAssessed(params: { journey_id: "J02", }), ]); - return { journey: "J02", eventBus: eventResult.status, lakehouse: lhResult.status }; + return { + journey: "J02", + eventBus: integrationOutcome(eventResult, "published"), + lakehouse: integrationOutcome(lhResult, "ingested"), + }; } export async function j03_violationDetected(params: { @@ -196,7 +211,12 @@ export async function j03_violationDetected(params: { journey_id: "J03", }), ]); - return { journey: "J03", eventBus: eventResult.status, workflow: wfResult.status, lakehouse: lhResult.status }; + return { + journey: "J03", + eventBus: integrationOutcome(eventResult, "published"), + workflow: integrationOutcome(wfResult, "started"), + lakehouse: integrationOutcome(lhResult, "ingested"), + }; } export async function j04_penaltyIssued(params: { @@ -304,7 +324,11 @@ export async function j17_certificateIssued(params: { journey_id: "J17", }), ]); - return { journey: "J17", eventBus: eventResult.status, lakehouse: lhResult.status }; + return { + journey: "J17", + eventBus: integrationOutcome(eventResult, "published"), + lakehouse: integrationOutcome(lhResult, "ingested"), + }; } export async function j18_revenueDistribution(params: { diff --git a/server/paymentCommand.postgres.integration.test.ts b/server/paymentCommand.postgres.integration.test.ts new file mode 100644 index 00000000..9a94c404 --- /dev/null +++ b/server/paymentCommand.postgres.integration.test.ts @@ -0,0 +1,130 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; +import { Client } from "pg"; +import { enqueuePaymentCommand, applyMojaloopPaymentCallback } from "./paymentCommandProcessor"; + +const testUrl = process.env.PAYMENT_COMMAND_TEST_DATABASE_URL; +const enabled = Boolean(testUrl); +const authority = testUrl?.split("@", 2)[1]?.split("/", 1)[0] ?? ""; +const localOnly = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(authority); +let client: Client | undefined; + +describe.skipIf(!enabled || !localOnly)("payment command PostgreSQL integration", () => { + beforeAll(async () => { + if (!testUrl) throw new Error("PAYMENT_COMMAND_TEST_DATABASE_URL is required"); + client = new Client({ connectionString: testUrl }); + await client.connect(); + await client.query(` + DROP TABLE IF EXISTS payment_commands CASCADE; + DROP TABLE IF EXISTS nip_transactions CASCADE; + DROP TABLE IF EXISTS rtgs_transactions CASCADE; + DROP TYPE IF EXISTS payment_command_status CASCADE; + DROP TYPE IF EXISTS payment_command_kind CASCADE; + DROP TYPE IF EXISTS nip_status CASCADE; + DROP TYPE IF EXISTS rtgs_status CASCADE; + CREATE TYPE nip_status AS ENUM ('initiated', 'processing', 'completed', 'failed', 'reversed', 'pending_confirmation'); + CREATE TYPE rtgs_status AS ENUM ('queued', 'processing', 'settled', 'rejected', 'cancelled', 'pending_funds'); + CREATE TABLE nip_transactions ( + id SERIAL PRIMARY KEY, + status nip_status NOT NULL DEFAULT 'initiated', + completed_at TIMESTAMPTZ, + response_code VARCHAR(10), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE rtgs_transactions ( + id SERIAL PRIMARY KEY, + status rtgs_status NOT NULL DEFAULT 'queued', + settled_at TIMESTAMPTZ, + rejection_reason TEXT + ); + `); + const migration = await readFile(new URL("../drizzle/0040_payment_command_durable_lifecycle.sql", import.meta.url), "utf8"); + await client.query(migration); + }); + + afterAll(async () => { + if (!client) return; + await client.query(` + DROP TABLE IF EXISTS payment_commands CASCADE; + DROP TABLE IF EXISTS nip_transactions CASCADE; + DROP TABLE IF EXISTS rtgs_transactions CASCADE; + DROP TYPE IF EXISTS payment_command_status CASCADE; + DROP TYPE IF EXISTS payment_command_kind CASCADE; + DROP TYPE IF EXISTS nip_status CASCADE; + DROP TYPE IF EXISTS rtgs_status CASCADE; + `).catch(() => undefined); + await client.end(); + }); + + it("persists an NIP command atomically with a pending ledger lifecycle and immutable target shape", async () => { + if (!client) throw new Error("Test PostgreSQL client unavailable"); + const nip = await client.query<{ id: number }>("INSERT INTO nip_transactions DEFAULT VALUES RETURNING id"); + await client.query("BEGIN"); + const command = await enqueuePaymentCommand(client, { + paymentKind: "nip", + paymentReference: "NIP-TEST-0001", + nipTransactionId: nip.rows[0].id, + amount: 500_000, + currency: "NGN", + debitAccount: "000001", + creditAccount: "000002", + }); + await client.query("COMMIT"); + + expect(command.status).toBe("pending_ledger"); + const stored = await client.query( + "SELECT payment_kind, status, amount, nip_transaction_id, rtgs_transaction_id FROM payment_commands WHERE id = $1", + [command.id], + ); + expect(stored.rows[0]).toMatchObject({ + payment_kind: "nip", + status: "pending_ledger", + amount: "500000", + nip_transaction_id: nip.rows[0].id, + rtgs_transaction_id: null, + }); + + await expect(client.query( + `INSERT INTO payment_commands (id, payment_kind, payment_reference, nip_transaction_id, rtgs_transaction_id, + amount, currency, debit_account, credit_account) + VALUES ('00000000-0000-4000-8000-000000000001', 'nip', 'NIP-TEST-INVALID', $1, $1, 1, 'NGN', '000001', '000002')`, + [nip.rows[0].id], + )).rejects.toThrow(); + }); + + it("finalizes only a pending-confirmation command and updates the matching NIP row", async () => { + if (!client) throw new Error("Test PostgreSQL client unavailable"); + const nip = await client.query<{ id: number }>("INSERT INTO nip_transactions DEFAULT VALUES RETURNING id"); + await client.query("BEGIN"); + await enqueuePaymentCommand(client, { + paymentKind: "nip", + paymentReference: "NIP-TEST-0002", + nipTransactionId: nip.rows[0].id, + amount: 1_000, + currency: "NGN", + debitAccount: "000001", + creditAccount: "000002", + }); + await client.query("COMMIT"); + await client.query(`UPDATE payment_commands + SET status = 'pending_confirmation', mojaloop_reference = 'NIP-TEST-0002' + WHERE payment_reference = 'NIP-TEST-0002'`); + + const originalUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = testUrl; + try { + await expect(applyMojaloopPaymentCallback("NIP-TEST-0002", "COMMITTED", new Date("2026-09-01T00:00:00.000Z"))).resolves.toBe("updated"); + await expect(applyMojaloopPaymentCallback("NIP-TEST-0002", "ABORTED", new Date("2026-09-01T00:01:00.000Z"))).resolves.toBe("illegal_transition"); + } finally { + if (originalUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalUrl; + } + + const command = await client.query("SELECT status, completed_at FROM payment_commands WHERE payment_reference = 'NIP-TEST-0002'"); + const payment = await client.query("SELECT status, response_code, completed_at FROM nip_transactions WHERE id = $1", [nip.rows[0].id]); + expect(command.rows[0].status).toBe("completed"); + expect(command.rows[0].completed_at).not.toBeNull(); + expect(payment.rows[0]).toMatchObject({ status: "completed", response_code: "00" }); + expect(payment.rows[0].completed_at).not.toBeNull(); + }); +}); diff --git a/server/paymentCommandProcessor.ts b/server/paymentCommandProcessor.ts new file mode 100644 index 00000000..b6892bcd --- /dev/null +++ b/server/paymentCommandProcessor.ts @@ -0,0 +1,312 @@ +import { randomUUID } from "node:crypto"; +import type { PoolClient } from "pg"; +import { getDb, getPool } from "./db"; +import { createTigerBeetleTransaction } from "./tigerbeetle"; +import { mojaloopTransfer } from "./middlewareExtensions"; +import { logger } from "./logger"; + +export type PaymentKind = "nip" | "rtgs"; +export type PaymentCommandStatus = + | "pending_ledger" + | "processing_ledger" + | "pending_settlement" + | "processing_settlement" + | "pending_confirmation" + | "completed" + | "failed"; + +interface PaymentCommandRow { + id: string; + payment_kind: PaymentKind; + payment_reference: string; + nip_transaction_id: number | null; + rtgs_transaction_id: number | null; + status: PaymentCommandStatus; + amount: number; + currency: "NGN" | "USD"; + debit_account: string; + credit_account: string; + tigerbeetle_transaction_id: string | null; + mojaloop_reference: string | null; + attempts: number; +} + +export interface EnqueuePaymentCommandInput { + paymentKind: PaymentKind; + paymentReference: string; + nipTransactionId?: number; + rtgsTransactionId?: number; + amount: number; + currency: "NGN" | "USD"; + debitAccount: string; + creditAccount: string; +} + +const MAX_ATTEMPTS = 5; +const LEASE_MS = 30_000; +const PROCESS_INTERVAL_MS = 1_000; +let processorTimer: NodeJS.Timeout | undefined; +let processorTickRunning = false; + +async function requiredPaymentPool() { + await getDb(); + const pool = getPool(); + if (!pool) throw new Error("Database unavailable"); + return pool; +} + +function assertInput(input: EnqueuePaymentCommandInput): void { + if (!/^[A-Za-z0-9._:-]{8,64}$/.test(input.paymentReference)) { + throw new Error("paymentReference must contain 8-64 safe identifier characters"); + } + if (!Number.isSafeInteger(input.amount) || input.amount <= 0) { + throw new Error("Payment command amount must be a positive safe integer in minor units"); + } + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(input.debitAccount) || !/^[A-Za-z0-9._:-]{1,128}$/.test(input.creditAccount)) { + throw new Error("Payment command account identifiers are invalid"); + } + const hasNip = input.nipTransactionId !== undefined; + const hasRtgs = input.rtgsTransactionId !== undefined; + if ((input.paymentKind === "nip" && (!hasNip || hasRtgs)) || (input.paymentKind === "rtgs" && (!hasRtgs || hasNip))) { + throw new Error("Payment command must reference exactly one matching NIP or RTGS transaction"); + } +} + +export async function enqueuePaymentCommand(client: PoolClient, input: EnqueuePaymentCommandInput): Promise<{ id: string; status: "pending_ledger" }> { + assertInput(input); + const id = randomUUID(); + const result = await client.query<{ id: string; status: "pending_ledger" }>( + `INSERT INTO payment_commands ( + id, payment_kind, payment_reference, nip_transaction_id, rtgs_transaction_id, + status, amount, currency, debit_account, credit_account + ) VALUES ($1, $2, $3, $4, $5, 'pending_ledger', $6, $7, $8, $9) + RETURNING id, status`, + [ + id, + input.paymentKind, + input.paymentReference, + input.nipTransactionId ?? null, + input.rtgsTransactionId ?? null, + input.amount, + input.currency, + input.debitAccount, + input.creditAccount, + ], + ); + if (result.rowCount !== 1 || !result.rows[0]) throw new Error("Failed to persist payment command"); + return result.rows[0]; +} + +function retryStatus(status: PaymentCommandStatus): "pending_ledger" | "pending_settlement" { + return status === "processing_settlement" ? "pending_settlement" : "pending_ledger"; +} + +export function retryDelayMs(attempts: number): number { + return Math.min(5 * 60_000, 1_000 * (2 ** Math.min(8, Math.max(0, attempts - 1)))); +} + +async function claimNextPaymentCommand(): Promise { + const pool = await requiredPaymentPool(); + const result = await pool.query( + `WITH candidate AS ( + SELECT id + FROM payment_commands + WHERE ( + status IN ('pending_ledger', 'pending_settlement') AND next_attempt_at <= NOW() + ) OR ( + status IN ('processing_ledger', 'processing_settlement') + AND lease_expires_at IS NOT NULL AND lease_expires_at < NOW() + ) + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE payment_commands command + SET status = CASE + WHEN command.status IN ('pending_ledger', 'processing_ledger') THEN 'processing_ledger'::payment_command_status + ELSE 'processing_settlement'::payment_command_status + END, + attempts = command.attempts + 1, + lease_expires_at = NOW() + INTERVAL '30 seconds', + updated_at = NOW() + FROM candidate + WHERE command.id = candidate.id + RETURNING command.*`, + ); + return result.rows[0]; +} + +async function scheduleRetry(command: PaymentCommandRow, error: unknown): Promise { + const pool = await requiredPaymentPool(); + const message = error instanceof Error ? error.message : String(error); + const terminal = command.attempts >= MAX_ATTEMPTS; + const nextStatus = terminal ? "failed" : retryStatus(command.status); + const nextAttemptAt = new Date(Date.now() + retryDelayMs(command.attempts)); + await pool.query( + `UPDATE payment_commands + SET status = $1::payment_command_status, + next_attempt_at = $2, + lease_expires_at = NULL, + last_error = $3, + completed_at = CASE WHEN $4 THEN NOW() ELSE completed_at END, + updated_at = NOW() + WHERE id = $5 AND status = $6::payment_command_status`, + [nextStatus, nextAttemptAt.toISOString(), message.slice(0, 4_000), terminal, command.id, command.status], + ); +} + +async function processClaimedPaymentCommand(command: PaymentCommandRow): Promise { + const pool = await requiredPaymentPool(); + try { + if (command.status === "processing_ledger") { + const result = await createTigerBeetleTransaction({ + orgId: command.debit_account, + penaltyId: command.payment_reference, + amount: command.amount, + currency: command.currency, + type: "transfer", + debitAccountId: command.debit_account, + creditAccountId: command.credit_account, + description: command.payment_kind === "nip" ? "NIP_TRANSFER" : "RTGS_TRANSFER", + }); + await pool.query( + `UPDATE payment_commands + SET status = 'pending_settlement', tigerbeetle_transaction_id = $1, + next_attempt_at = NOW(), lease_expires_at = NULL, last_error = NULL, updated_at = NOW() + WHERE id = $2 AND status = 'processing_ledger'`, + [result.transactionId, command.id], + ); + return; + } + + if (command.status === "processing_settlement") { + await mojaloopTransfer({ + payerFsp: command.debit_account, + payeeFsp: command.credit_account, + amount: String(command.amount), + currency: command.currency, + reference: command.payment_reference, + note: `${command.payment_kind.toUpperCase()}:${command.payment_reference}`, + }); + await pool.query( + `UPDATE payment_commands + SET status = 'pending_confirmation', mojaloop_reference = $1, + lease_expires_at = NULL, last_error = NULL, updated_at = NOW() + WHERE id = $2 AND status = 'processing_settlement'`, + [command.payment_reference, command.id], + ); + } + } catch (error) { + await scheduleRetry(command, error).catch((retryError) => { + logger.error({ err: retryError, paymentReference: command.payment_reference }, "[Payments] Failed to persist payment command retry state"); + }); + logger.warn({ err: error, paymentReference: command.payment_reference, attempts: command.attempts }, "[Payments] Durable payment command attempt failed"); + } +} + +async function processTick(): Promise { + if (processorTickRunning) return; + processorTickRunning = true; + try { + const command = await claimNextPaymentCommand(); + if (command) await processClaimedPaymentCommand(command); + } catch (error) { + logger.error({ err: error }, "[Payments] Payment command processor tick failed"); + } finally { + processorTickRunning = false; + } +} + +export type MojaloopCallbackState = "COMMITTED" | "ABORTED" | "RESERVED"; +export type PaymentCallbackResult = "updated" | "idempotent" | "not_found" | "illegal_transition"; + +export async function applyMojaloopPaymentCallback( + paymentReference: string, + state: MojaloopCallbackState, + completedAt: Date, +): Promise { + const pool = await requiredPaymentPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const current = await client.query( + `SELECT * FROM payment_commands + WHERE payment_reference = $1 OR mojaloop_reference = $1 + FOR UPDATE`, + [paymentReference], + ); + if (current.rowCount !== 1 || !current.rows[0]) { + await client.query("ROLLBACK"); + return "not_found"; + } + const command = current.rows[0]; + if (state === "RESERVED") { + const accepted = command.status === "pending_confirmation" || command.status === "completed" || command.status === "failed"; + await client.query("COMMIT"); + return accepted ? "idempotent" : "illegal_transition"; + } + + const terminalStatus: "completed" | "failed" = state === "COMMITTED" ? "completed" : "failed"; + if (command.status === terminalStatus) { + await client.query("COMMIT"); + return "idempotent"; + } + if (command.status !== "pending_confirmation") { + await client.query("ROLLBACK"); + return "illegal_transition"; + } + + const updated = await client.query( + `UPDATE payment_commands + SET status = $1::payment_command_status, completed_at = $2, + lease_expires_at = NULL, last_error = NULL, updated_at = NOW() + WHERE id = $3 AND status = 'pending_confirmation' + RETURNING id`, + [terminalStatus, completedAt.toISOString(), command.id], + ); + if (updated.rowCount !== 1) throw new Error("Payment command state changed during callback transaction"); + + if (command.payment_kind === "nip") { + const nipStatus = terminalStatus === "completed" ? "completed" : "failed"; + await client.query( + `UPDATE nip_transactions + SET status = $1::nip_status, completed_at = $2, + response_code = CASE WHEN $1 = 'completed' THEN '00' ELSE response_code END + WHERE id = $3`, + [nipStatus, completedAt.toISOString(), command.nip_transaction_id], + ); + } else { + const rtgsStatus = terminalStatus === "completed" ? "settled" : "rejected"; + await client.query( + `UPDATE rtgs_transactions + SET status = $1::rtgs_status, + settled_at = CASE WHEN $1 = 'settled' THEN $2 ELSE settled_at END, + rejection_reason = CASE WHEN $1 = 'rejected' THEN 'Mojaloop transfer aborted' ELSE rejection_reason END + WHERE id = $3`, + [rtgsStatus, completedAt.toISOString(), command.rtgs_transaction_id], + ); + } + await client.query("COMMIT"); + return "updated"; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +export function startPaymentCommandProcessor(): void { + if (processorTimer) return; + processorTimer = setInterval(() => { void processTick(); }, PROCESS_INTERVAL_MS); + processorTimer.unref?.(); + void processTick(); +} + +export function stopPaymentCommandProcessor(): void { + if (!processorTimer) return; + clearInterval(processorTimer); + processorTimer = undefined; +} + +export const __test__ = { assertInput, retryDelayMs, retryStatus }; diff --git a/server/permify.ts b/server/permify.ts index 3d5b3220..3cfb79c0 100644 --- a/server/permify.ts +++ b/server/permify.ts @@ -23,6 +23,21 @@ import { logger } from "./logger"; const PERMIFY_URL = process.env.PERMIFY_URL ?? "http://localhost:3476"; const PERMIFY_TENANT = process.env.PERMIFY_TENANT ?? "ndsep"; +const PERMIFY_AUTH_TOKEN = process.env.PERMIFY_AUTH_TOKEN ?? ""; +const IS_PRODUCTION = process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; + +function permifyConfigurationError(): string | undefined { + if (!IS_PRODUCTION) return undefined; + if (!/^https:\/\//.test(PERMIFY_URL)) return "production Permify requires an https:// PERMIFY_URL"; + if (PERMIFY_AUTH_TOKEN.length < 32) return "production Permify requires a high-entropy PERMIFY_AUTH_TOKEN"; + return undefined; +} + +function permifyHeaders(): Record { + const headers: Record = { "Content-Type": "application/json" }; + if (PERMIFY_AUTH_TOKEN) headers.Authorization = `Bearer ${PERMIFY_AUTH_TOKEN}`; + return headers; +} export type PermifyAction = string; @@ -41,6 +56,11 @@ export async function permifyCheck( resourceType: string, resourceId: string | number ): Promise { + const configurationError = permifyConfigurationError(); + if (configurationError) { + logger.error({ configurationError }, "[permify] Refusing insecure production authorization check"); + return false; + } try { const body = { metadata: { schema_version: "", snap_token: "", depth: 20 }, @@ -53,7 +73,7 @@ export async function permifyCheck( `${PERMIFY_URL}/v1/tenants/${PERMIFY_TENANT}/permissions/check`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: permifyHeaders(), body: JSON.stringify(body), signal: AbortSignal.timeout(2000), } @@ -104,6 +124,8 @@ export async function permifyWriteRelationship( subjectType: string, subjectId: string | number ): Promise { + const configurationError = permifyConfigurationError(); + if (configurationError) throw new Error(configurationError); try { const body = { metadata: { schema_version: "" }, @@ -120,7 +142,7 @@ export async function permifyWriteRelationship( `${PERMIFY_URL}/v1/tenants/${PERMIFY_TENANT}/relationships/write`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: permifyHeaders(), body: JSON.stringify(body), signal: AbortSignal.timeout(2000), } @@ -142,6 +164,10 @@ export async function permifyHealthCheck(): Promise<{ latencyMs: number; }> { const start = Date.now(); + const configurationError = permifyConfigurationError(); + if (configurationError) { + return { connected: false, url: PERMIFY_URL, tenant: PERMIFY_TENANT, latencyMs: 0 }; + } try { const res = await fetch(`${PERMIFY_URL}/healthz`, { signal: AbortSignal.timeout(2000), @@ -157,6 +183,7 @@ export async function permifyHealthCheck(): Promise<{ * Idempotent — safe to call on every startup. */ export async function permifyBootstrapSchema(): Promise { + if (permifyConfigurationError()) return false; const NDSEP_SCHEMA = ` entity user {} @@ -187,7 +214,7 @@ entity sector { `${PERMIFY_URL}/v1/tenants/${PERMIFY_TENANT}/schemas/write`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: permifyHeaders(), body: JSON.stringify({ schema: NDSEP_SCHEMA }), signal: AbortSignal.timeout(5000), } diff --git a/server/phase25.test.ts b/server/phase25.test.ts index 609358f4..86330d33 100644 --- a/server/phase25.test.ts +++ b/server/phase25.test.ts @@ -247,8 +247,11 @@ describe("middlewareExtensions — lakehouseIngest", () => { describe("middlewareExtensions — tigerbeetleTransfer", () => { beforeEach(() => mockFetch.mockReset()); - it("posts to /transfers with debit/credit accounts", async () => { - mockFetch.mockResolvedValue(mockOk()); + it("posts an account-bound transfer to the durable TigerBeetle transaction endpoint", async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ + transaction_id: "tb-transfer-001", + ledger_entry_id: "tb-transfer-001", + }), { status: 201, headers: { "Content-Type": "application/json" } })); const { tigerbeetleTransfer } = await import("./middlewareExtensions"); await tigerbeetleTransfer({ debitAccountId: "ACC-001", @@ -259,9 +262,12 @@ describe("middlewareExtensions — tigerbeetleTransfer", () => { }); const call = mockFetch.mock.calls[0]; const body = JSON.parse(call[1].body); + expect(String(call[0])).toContain("/transaction"); expect(body.debit_account_id).toBe("ACC-001"); + expect(body.credit_account_id).toBe("ACC-002"); expect(body.amount).toBe(500000); - expect(body.transfer_type).toBe("REGULATORY_FINE"); + expect(body.type).toBe("transfer"); + expect(call[1].headers["Idempotency-Key"]).toMatch(/^[a-f0-9]{64}$/); }); }); diff --git a/server/productionReadiness.test.ts b/server/productionReadiness.test.ts index 3baa610e..2717dae9 100644 --- a/server/productionReadiness.test.ts +++ b/server/productionReadiness.test.ts @@ -113,11 +113,12 @@ describe("Area 2: Inter-service HTTP wiring (retries + circuit breakers)", () => expect(src).toContain("AbortSignal.timeout"); }); - it("Kafka event bus has retry queue", () => { + it("Kafka event bus persists retryable events in the PostgreSQL outbox", () => { const src = readFile("eventBus.ts"); - expect(src).toContain("retryQueue"); - expect(src).toContain("processRetryQueue"); - expect(src).toContain("MAX_RETRY_QUEUE"); + expect(src).toContain("domain_event_outbox"); + expect(src).toContain("FOR UPDATE SKIP LOCKED"); + expect(src).toContain("processDurableOutbox"); + expect(src).not.toContain("retryQueue"); }); it("Redis reconnects with exponential backoff", () => { @@ -331,9 +332,15 @@ describe("Area 6: Graceful degradation across the platform", () => { expect(src).toContain("return false"); }); - it("rate limiter falls back to in-memory when Redis unavailable", () => { - const src = readFile("rateLimiter.ts"); - expect(src).toContain("Falls back to in-memory"); + it("rate limiters use Redis outside explicit tests and fail closed on store errors", () => { + const limiter = readFile("rateLimiter.ts"); + const store = readFile("redisRateLimitStore.ts"); + expect(limiter).toContain("redisRateLimitStore"); + expect(limiter).toContain("passOnStoreError: false"); + expect(store).toContain('process.env.NODE_ENV === "test"'); + expect(store).toContain("REDIS_URL is required for non-test rate limiting"); + expect(store).toContain("rediss://"); + expect(limiter).not.toContain("Falls back to in-memory"); }); it("orchestration service calls degrade gracefully on failure", () => { @@ -348,10 +355,11 @@ describe("Area 6: Graceful degradation across the platform", () => { expect(src).toContain("backoff"); }); - it("Kafka event bus queues events when unavailable", () => { + it("Kafka event bus reschedules unavailable events from durable PostgreSQL state", () => { const src = readFile("eventBus.ts"); - expect(src).toContain("Queued for retry"); - expect(src).toContain("Kafka unavailable"); + expect(src).toContain("durable outbox event rescheduled"); + expect(src).toContain("next_attempt_at"); + expect(src).toContain("lease_expires_at"); }); it("middlewareConnector circuit breaker opens after repeated failures", () => { diff --git a/server/rateLimiter.ts b/server/rateLimiter.ts index 1115f8f3..1375a967 100644 --- a/server/rateLimiter.ts +++ b/server/rateLimiter.ts @@ -1,119 +1,97 @@ /** - * Rate Limiting Middleware — NDSEP Enhancement (Priority 4) - * Per-organisation and per-IP rate limiting using express-rate-limit. - * Falls back to in-memory store when Redis is unavailable. + * Rate Limiting Middleware — distributed Redis-backed enforcement in runtime. + * Test runs use isolated express-rate-limit memory stores only. */ import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import type { Request } from "express"; +import { redisRateLimitStore } from "./redisRateLimitStore"; -// ─── Helpers ──────────────────────────────────────────────────────────────── - -/** IPv6-safe IP key: normalises /56 subnets so IPv6 users can't bypass limits */ function safeIpKey(req: Request): string { const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; return ipKeyGenerator(ip); } function getOrgKey(req: Request): string { - // Use org ID from session if available, otherwise fall back to IP (IPv6-safe) - const orgId = (req as any).session?.orgId ?? (req as any).user?.orgId; - if (orgId) return `org:${orgId}`; - return safeIpKey(req); + const orgId = (req as { session?: { orgId?: string }; user?: { orgId?: string } }).session?.orgId + ?? (req as { session?: { orgId?: string }; user?: { orgId?: string } }).user?.orgId; + return orgId ? `org:${orgId}` : safeIpKey(req); } function standardHeaders() { - return { - standardHeaders: "draft-7" as const, - legacyHeaders: false, - }; + return { standardHeaders: "draft-7" as const, legacyHeaders: false, passOnStoreError: false }; } -// ─── Rate Limiters ────────────────────────────────────────────────────────── - -/** - * Global API rate limiter — 1000 req/15min per IP. - * Applied to all /api/* routes. - */ +/** Global API rate limiter — 1,000 requests per 15 minutes per source IP. */ export const globalApiLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes + windowMs: 15 * 60 * 1000, max: 1000, message: { error: "Too many requests. Please try again later.", code: "RATE_LIMITED" }, keyGenerator: safeIpKey, skip: (req: Request) => req.path === "/api/health", + store: redisRateLimitStore("ndsep:global-api:"), ...standardHeaders(), }); -/** - * Auth rate limiter — 20 req/15min per IP. - * Applied to /api/oauth/* routes to prevent brute force. - */ +/** Authentication rate limiter — 20 requests per 15 minutes per source IP. */ export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, message: { error: "Too many authentication attempts. Please wait 15 minutes.", code: "AUTH_RATE_LIMITED" }, keyGenerator: safeIpKey, + store: redisRateLimitStore("ndsep:auth:"), ...standardHeaders(), }); -/** - * tRPC mutation rate limiter — 200 mutations/15min per org. - * Applied to /api/trpc POST requests. - */ +/** tRPC mutation limiter — 200 write operations per 15 minutes per organization. */ export const trpcMutationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200, message: { error: "Too many write operations. Please slow down.", code: "MUTATION_RATE_LIMITED" }, - keyGenerator: (req: Request) => getOrgKey(req), + keyGenerator: getOrgKey, skip: (req: Request) => req.method !== "POST", + store: redisRateLimitStore("ndsep:trpc-mutation:"), ...standardHeaders(), }); -/** - * File upload rate limiter — 50 uploads/hour per org. - * Applied to /api/evidence/upload and /api/dsar/upload. - */ +/** File upload limiter — 50 uploads per hour per organization. */ export const uploadLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour + windowMs: 60 * 60 * 1000, max: 50, message: { error: "Upload limit reached. Maximum 50 uploads per hour.", code: "UPLOAD_RATE_LIMITED" }, keyGenerator: getOrgKey, + store: redisRateLimitStore("ndsep:upload:"), ...standardHeaders(), }); -/** - * Stripe webhook — no rate limit (Stripe controls delivery). - * Public DSAR submission — 10 req/hour per IP to prevent spam. - */ +/** Public DSAR limiter — 10 submissions per hour per source IP. */ export const dsarPublicLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 10, message: { error: "Too many DSAR submissions from this IP. Please wait before submitting again.", code: "DSAR_RATE_LIMITED" }, keyGenerator: safeIpKey, + store: redisRateLimitStore("ndsep:dsar:"), ...standardHeaders(), }); -/** - * BGP SSE stream — 5 concurrent connections per IP. - */ +/** BGP SSE limiter — five stream openings per minute per source IP. */ export const bgpSseLimiter = rateLimit({ windowMs: 60 * 1000, max: 5, message: { error: "Too many SSE connections.", code: "SSE_RATE_LIMITED" }, keyGenerator: safeIpKey, + store: redisRateLimitStore("ndsep:bgp-sse:"), ...standardHeaders(), }); -/** - * Developer API — 500 req/hour per API key. - */ +/** Developer API limiter — 500 requests per hour per API key or source IP. */ export const developerApiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 500, - message: { error: "API rate limit exceeded. Upgrade your plan for higher limits.", code: "API_KEY_RATE_LIMITED" }, + message: { error: "API rate limit exceeded.", code: "API_KEY_RATE_LIMITED" }, keyGenerator: (req: Request) => { - const apiKey = req.headers["x-api-key"] as string; - if (apiKey) return `apikey:${apiKey}`; - return safeIpKey(req); + const apiKey = req.headers["x-api-key"] as string | undefined; + return apiKey ? `apikey:${apiKey}` : safeIpKey(req); }, + store: redisRateLimitStore("ndsep:developer-api:"), ...standardHeaders(), }); diff --git a/server/readReplica.ts b/server/readReplica.ts index 19b2b9bd..96206ce8 100644 --- a/server/readReplica.ts +++ b/server/readReplica.ts @@ -11,6 +11,7 @@ import pg from "pg"; import { getPgSslConfig } from "./dbSslConfig"; +import { getPrimaryDatabasePoolOptions } from "./dbPoolConfig"; import { logger } from "./logger"; import { handleError } from "./errorClassifier"; @@ -21,17 +22,16 @@ let _replicaAvailable = false; export function initReadReplica(): boolean { const replicaUrl = process.env.DATABASE_REPLICA_URL; if (!replicaUrl) { + _replicaAvailable = false; logger.info("[ReadReplica] DATABASE_REPLICA_URL not set — using primary for all queries"); return false; } try { _readPool = new pg.Pool({ - connectionString: replicaUrl, - ssl: getPgSslConfig(), - max: 20, - idleTimeoutMillis: 30_000, - connectionTimeoutMillis: 5_000, + ...getPrimaryDatabasePoolOptions(replicaUrl, getPgSslConfig(replicaUrl)), + application_name: process.env.DB_REPLICA_APPLICATION_NAME ?? "ndsep-api-read", + allowExitOnIdle: process.env.NODE_ENV === "test", }); _readPool.on("error", (err) => { diff --git a/server/redisRateLimitStore.test.ts b/server/redisRateLimitStore.test.ts new file mode 100644 index 00000000..a284e8cb --- /dev/null +++ b/server/redisRateLimitStore.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalEnv = { ...process.env }; + +function restoreEnvironment(): void { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + vi.resetModules(); +} + +afterEach(restoreEnvironment); + +describe("Redis rate-limit store configuration", () => { + it("uses express-rate-limit's isolated memory store only in explicit test execution", async () => { + process.env.NODE_ENV = "test"; + delete process.env.REDIS_URL; + const module = await import("./redisRateLimitStore"); + expect(module.redisRateLimitStore("ndsep:test:")).toBeUndefined(); + }); + + it("rejects a non-test runtime without an explicit Redis endpoint", async () => { + process.env.NODE_ENV = "development"; + delete process.env.APP_ENV; + delete process.env.REDIS_URL; + await expect(import("./redisRateLimitStore")).rejects.toThrow("REDIS_URL is required for non-test rate limiting"); + }); + + it("rejects production rate limiting without certificate-validated rediss configuration", async () => { + process.env.NODE_ENV = "production"; + process.env.REDIS_URL = "redis://cache.internal:6379"; + process.env.REDIS_TLS = "false"; + await expect(import("./redisRateLimitStore")).rejects.toThrow("Production rate limiting requires REDIS_URL to use rediss://"); + }); +}); diff --git a/server/redisRateLimitStore.ts b/server/redisRateLimitStore.ts new file mode 100644 index 00000000..2791a1bb --- /dev/null +++ b/server/redisRateLimitStore.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import Redis from "ioredis"; +import RedisStore, { type RedisReply } from "rate-limit-redis"; + +const isTest = process.env.NODE_ENV === "test"; +const isProduction = process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; +const redisUrl = process.env.REDIS_URL; +const tlsEnabled = process.env.REDIS_TLS === "true"; +const tlsCaPath = process.env.REDIS_TLS_CA_PATH; + +function requiredRedisUrl(): string { + if (!redisUrl) throw new Error("REDIS_URL is required for non-test rate limiting"); + if (isProduction) { + if (!redisUrl.startsWith("rediss://")) throw new Error("Production rate limiting requires REDIS_URL to use rediss://"); + if (!tlsEnabled || process.env.REDIS_TLS_REJECT_UNAUTHORIZED === "false") { + throw new Error("Production rate limiting requires REDIS_TLS=true with certificate verification"); + } + if (!tlsCaPath || !fs.existsSync(tlsCaPath)) { + throw new Error("Production rate limiting requires a readable REDIS_TLS_CA_PATH"); + } + } + return redisUrl; +} + +function redisTlsOptions(): Record | undefined { + if (!tlsEnabled) return undefined; + const options: Record = { + rejectUnauthorized: process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false", + }; + if (tlsCaPath && fs.existsSync(tlsCaPath)) options.ca = [fs.readFileSync(tlsCaPath)]; + return options; +} + +// Test suites intentionally use express-rate-limit's isolated memory store. Runtime +// request throttling is distributed through Redis; a store error rejects requests +// because passOnStoreError remains false on every limiter. +const rateLimitRedis = isTest + ? undefined + : new Redis(requiredRedisUrl(), { + lazyConnect: true, + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + tls: redisTlsOptions() as never, + }); + +if (rateLimitRedis) { + rateLimitRedis.on("error", () => undefined); + void rateLimitRedis.connect().catch(() => undefined); +} + +export function redisRateLimitStore(prefix: string): RedisStore | undefined { + if (!rateLimitRedis) return undefined; + return new RedisStore({ + prefix, + sendCommand: (command: string, ...args: string[]) => + rateLimitRedis.call(command, ...args) as Promise, + }); +} + +export const __test__ = { isTest, isProduction, requiredRedisUrl, redisTlsOptions }; diff --git a/server/routers.ts b/server/routers.ts index de84ebb7..1fe3772a 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -250,6 +250,7 @@ import { import { phase12Router } from "./routers/phase12Features"; import { phase13Router } from "./routers/phase13Features"; import { productionReadinessRouter } from "./routers/productionReadiness"; +import { mlFoundationRouter } from "./routers/mlFoundation"; import { temporalRouter, searchRouter as opensearchRouter, wafRouter, gatewayRouter, authzRouter, kafkaMetricsRouter, ledgerRouter } from "./routers/middlewareWiring"; import { osirisIntelRouter } from "./routers/osirisIntel"; import { socintRouter } from "./routers/socint"; @@ -316,8 +317,12 @@ export const appRouter = router({ insurance: insuranceRouter, fintech: fintechRouter, workflows: workflowRouter, + mlFoundation: mlFoundationRouter, auth: router({ - me: publicProcedure.query(opts => opts.ctx.user), + // tRPC must receive an explicit null for an unauthenticated request. Returning + // undefined serializes to an empty HTTP 200 response, which leaves client + // query consumers in an indeterminate loading state. + me: publicProcedure.query(opts => opts.ctx.user ?? null), logout: publicProcedure.mutation(async ({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); // Blacklist the JWT token so it cannot be reused even before expiry @@ -816,7 +821,7 @@ export const appRouter = router({ createTigerBeetleTransaction({ orgId: String(input.organizationId), penaltyId: String(penalty?.id ?? 0), - amountUsd: input.amount, + amount: input.amount, currency: input.currency, type: "penalty", description: input.description, @@ -914,7 +919,7 @@ export const appRouter = router({ createTigerBeetleTransaction({ orgId: String(resRec.organizationId ?? 0), penaltyId: String(resRec.penaltyId), - amountUsd: Number(resRec.amount ?? 0), + amount: Number(resRec.amount ?? 0), currency: String(resRec.currency ?? "USD"), type: "settlement", description: `Appeal ${input.appealId} upheld — penalty settled`, @@ -1002,7 +1007,7 @@ export const appRouter = router({ createTigerBeetleTransaction({ orgId: String(r.orgId), penaltyId: String(r.penaltyId), - amountUsd: input.amount, + amount: input.amount, currency: input.currency, type: "penalty", description: input.description, diff --git a/server/routers/banking.ts b/server/routers/banking.ts index 9790ec35..e72df0fc 100644 --- a/server/routers/banking.ts +++ b/server/routers/banking.ts @@ -17,6 +17,7 @@ import pg from "pg"; import { getPgSslConfig } from "../dbSslConfig"; import { getDatabaseUrl } from "../config"; import { logger } from "../logger"; +import { enqueuePaymentCommand, type EnqueuePaymentCommandInput } from "../paymentCommandProcessor"; const { Pool } = pg; let _pool: InstanceType | null = null; function getPool(): InstanceType { @@ -37,6 +38,37 @@ async function query(sql: string, params: unknown[] = []): Promise { return autoDecryptRows(finalSql, rows); } +/** + * Atomically persists one payment instruction and its durable external-work command. + * The processor is allowed to retry after this commit; no downstream call is made in + * the request transaction, so a database rollback cannot create an orphaned command. + */ +async function persistPaymentAndCommand( + paymentInsertSql: string, + paymentParams: unknown[], + command: EnqueuePaymentCommandInput, +): Promise<{ paymentId: number; commandId: string; status: "pending_ledger" }> { + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const inserted = await client.query<{ id: number }>(paymentInsertSql, paymentParams); + if (inserted.rowCount !== 1 || !inserted.rows[0]) throw new Error("Payment record was not persisted"); + const paymentId = inserted.rows[0].id; + const persistedCommand = await enqueuePaymentCommand(client, { + ...command, + nipTransactionId: command.paymentKind === "nip" ? paymentId : undefined, + rtgsTransactionId: command.paymentKind === "rtgs" ? paymentId : undefined, + }); + await client.query("COMMIT"); + return { paymentId, commandId: persistedCommand.id, status: persistedCommand.status }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + // ─── Helper: generate unique references ────────────────────────────────────── function genRef(prefix: string): string { const ts = Date.now().toString(36).toUpperCase(); @@ -772,15 +804,24 @@ const paymentsRouter = router({ const sessionId = genRef("NIP").replace("-", "").substring(0, 40); const nibssRef = `NIBSS${Date.now()}`; - await query(` - INSERT INTO nip_transactions (session_id, sender_bank_code, sender_bank_name, sender_account_number, sender_account_name, - receiver_bank_code, receiver_bank_name, receiver_account_number, receiver_account_name, - amount, narration, status, nibss_ref, channel_code, aml_flagged, fraud_flagged) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'initiated', ?, ?, ?, ?) - `, [sessionId, input.senderBankCode, null, input.senderAccountNumber, input.senderAccountName ?? null, + const persistedPayment = await persistPaymentAndCommand( + `INSERT INTO nip_transactions (session_id, sender_bank_code, sender_bank_name, sender_account_number, sender_account_name, + receiver_bank_code, receiver_bank_name, receiver_account_number, receiver_account_name, + amount, narration, status, nibss_ref, channel_code, aml_flagged, fraud_flagged) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'initiated', $12, $13, $14, $15) + RETURNING id`, + [sessionId, input.senderBankCode, null, input.senderAccountNumber, input.senderAccountName ?? null, input.receiverBankCode, null, input.receiverAccountNumber, input.receiverAccountName ?? null, - input.amount, input.narration ?? null, nibssRef, input.channelCode ?? "API", - amlFlagged ? 1 : 0, fraudFlagged ? 1 : 0]); + input.amount, input.narration ?? null, nibssRef, input.channelCode ?? "API", amlFlagged, fraudFlagged], + { + paymentKind: "nip", + paymentReference: sessionId, + amount: input.amount, + currency: "NGN", + debitAccount: input.senderBankCode, + creditAccount: input.receiverBankCode, + }, + ); // Auto-create AML case for flagged transactions if (amlFlagged || structuringRisk) { @@ -796,28 +837,18 @@ const paymentsRouter = router({ } catch { /* non-fatal */ } } - // ── Mojaloop: initiate interbank settlement via payment switch ── - mojaloopTransfer({ - payerFsp: input.senderBankCode, - payeeFsp: input.receiverBankCode, - amount: String(input.amount), - currency: "NGN", - reference: sessionId, - note: `NIP:${nibssRef}`, - }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "[Mojaloop] NIP settlement fire-and-forget")); - - // ── TigerBeetle: record NIP transfer in financial ledger ── - tigerbeetleTransfer({ - debitAccountId: input.senderBankCode, - creditAccountId: input.receiverBankCode, - amount: input.amount, - currency: "NGN", - reference: sessionId, - transferType: "NIP_TRANSFER", - }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "[TigerBeetle] NIP ledger fire-and-forget")); - emitMutationEvent(EVENTS.SWIFT_TRANSACTION, { action: "nip_initiate", sessionId, amount: input.amount, amlFlagged, fraudFlagged, structuringRisk, velocityFlagged, ts: new Date().toISOString() }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); - return { success: true, sessionId, nibssRef, amlFlagged, fraudFlagged, structuringRisk, velocityFlagged }; + return { + accepted: true, + sessionId, + nibssRef, + paymentCommandId: persistedPayment.commandId, + settlementStatus: persistedPayment.status, + amlFlagged, + fraudFlagged, + structuringRisk, + velocityFlagged, + }; }), // RTGS Transactions @@ -912,13 +943,23 @@ const paymentsRouter = router({ // Business rule: Determine settlement cycle based on time of day const hour = new Date().getHours(); const settlementCycle = hour < 12 ? "AM" : hour < 16 ? "PM1" : "PM2"; - await query(` - INSERT INTO rtgs_transactions (reference, sender_bank_code, sender_account_number, receiver_bank_code, - receiver_account_number, amount, narration, status, priority, settlement_cycle, cbn_ref) - VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?) - `, [reference, input.senderBankCode, input.senderAccountNumber ?? null, input.receiverBankCode, + const persistedPayment = await persistPaymentAndCommand( + `INSERT INTO rtgs_transactions (reference, sender_bank_code, sender_account_number, receiver_bank_code, + receiver_account_number, amount, narration, status, priority, settlement_cycle, cbn_ref) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10) + RETURNING id`, + [reference, input.senderBankCode, input.senderAccountNumber ?? null, input.receiverBankCode, input.receiverAccountNumber ?? null, input.amount, input.narration ?? null, - input.priority, settlementCycle, cbnRef]); + input.priority, settlementCycle, cbnRef], + { + paymentKind: "rtgs", + paymentReference: reference, + amount: input.amount, + currency: "NGN", + debitAccount: input.senderBankCode, + creditAccount: input.receiverBankCode, + }, + ); // Auto-create AML case for all RTGS (always above CTR threshold) try { @@ -930,28 +971,18 @@ const paymentsRouter = router({ input.amount]); } catch { /* non-fatal */ } - // ── Mojaloop: initiate RTGS settlement via payment switch ── - mojaloopTransfer({ - payerFsp: input.senderBankCode, - payeeFsp: input.receiverBankCode, - amount: String(input.amount), - currency: "NGN", - reference, - note: `RTGS:${cbnRef}:${settlementCycle}`, - }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "[Mojaloop] RTGS settlement fire-and-forget")); - - // ── TigerBeetle: record RTGS transfer in financial ledger ── - tigerbeetleTransfer({ - debitAccountId: input.senderBankCode, - creditAccountId: input.receiverBankCode, - amount: input.amount, - currency: "NGN", - reference, - transferType: "RTGS_TRANSFER", - }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "[TigerBeetle] RTGS ledger fire-and-forget")); - emitMutationEvent(EVENTS.SWIFT_TRANSACTION, { action: "rtgs_initiate", reference, amount: input.amount, amlFlagged, enhancedDueDiligence, sanctionsFlagged, velocityFlagged, ts: new Date().toISOString() }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); - return { success: true, reference, cbnRef, settlementCycle, amlFlagged, enhancedDueDiligence, velocityFlagged }; + return { + accepted: true, + reference, + cbnRef, + settlementCycle, + paymentCommandId: persistedPayment.commandId, + settlementStatus: persistedPayment.status, + amlFlagged, + enhancedDueDiligence, + velocityFlagged, + }; }), paymentStats: protectedProcedure.query(async () => { diff --git a/server/routers/dpco.ts b/server/routers/dpco.ts index 3da13283..f11da377 100644 --- a/server/routers/dpco.ts +++ b/server/routers/dpco.ts @@ -423,6 +423,53 @@ const trainingSessionInput = z.object({ cpeCredits: z.number().optional(), }); +function isProductionRuntime(): boolean { + return process.env.NODE_ENV === "production" || process.env.APP_ENV === "production"; +} + +function getAnalyticsServiceBaseUrl(): URL { + const configured = process.env.DPCO_ANALYTICS_URL; + const fallback = `http://127.0.0.1:${process.env.DPCO_ANALYTICS_PORT ?? 8330}`; + const url = new URL(configured ?? fallback); + if (isProductionRuntime()) { + if (!configured || url.protocol !== "https:" || ["localhost", "127.0.0.1", "::1"].includes(url.hostname)) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DPCO analytics requires a configured non-local HTTPS service URL in production" }); + } + if ((process.env.DPCO_ANALYTICS_SERVICE_TOKEN ?? "").length < 32) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DPCO analytics service credential is not configured" }); + } + } + return url; +} + +async function requestAnalyticsService(path: string): Promise> { + const baseUrl = getAnalyticsServiceBaseUrl(); + const target = new URL(path, baseUrl); + if (target.origin !== baseUrl.origin) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DPCO analytics service path" }); + } + try { + const token = process.env.DPCO_ANALYTICS_SERVICE_TOKEN; + const response = await fetch(target, { + headers: token ? { "X-NDSEP-Service-Token": token } : {}, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + logger.warn({ status: response.status, path }, "DPCO analytics service request failed"); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "DPCO analytics persistence service is unavailable" }); + } + const data: unknown = await response.json(); + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DPCO analytics service returned an invalid response" }); + } + return data as Record; + } catch (error) { + if (error instanceof TRPCError) throw error; + logger.warn({ err: error, path }, "DPCO analytics service request failed"); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "DPCO analytics persistence service is unavailable" }); + } +} + const policyDraftInput = z.object({ id: z.number().int().optional(), dpcoId: z.number().int().optional(), @@ -537,38 +584,17 @@ export const dpcoRouter = router({ return row; }), - // ── Microservice Bridge Procedures ────────────────────────────────────────────────── - // Proxy to Go/Python DPCO microservices; fallback to DB when services are not reachable. - + // ── Durable Analytics Microservice Bridge ─────────────────────────────────────────── + // These routes expose only the Python service's PostgreSQL-backed contract. They do not + // synthesize alternate response shapes or present stale fallback data as live analytics. analyticsComplianceTrends: protectedProcedure - .query(async () => { - try { - const res = await fetch(`http://localhost:${process.env.DPCO_ANALYTICS_PORT ?? 8330}/api/dpco/analytics/trends`, { signal: AbortSignal.timeout(5000) }); - if (res.ok) return res.json(); - } catch (e: unknown) { logger.debug({ err: e instanceof Error ? e.message : String(e) }, "DPCO analytics trends service unavailable, falling back to DB"); } - const rows = await q("SELECT TO_CHAR(snapshot_date, 'IYYY-IW') as week, ROUND(AVG(composite_score)::numeric,1) as avg_score, COUNT(*) as audits FROM ndpa_compliance_snapshots GROUP BY week ORDER BY week DESC LIMIT 26", []); - return { weeks: rows, total_weeks: rows.length, source: "db-fallback" }; - }), + .query(() => requestAnalyticsService("/api/dpco/analytics/trends")), analyticsPortfolio: protectedProcedure - .query(async () => { - try { - const res = await fetch(`http://localhost:${process.env.DPCO_ANALYTICS_PORT ?? 8330}/api/dpco/analytics/portfolio`, { signal: AbortSignal.timeout(5000) }); - if (res.ok) return res.json(); - } catch (e: unknown) { logger.debug({ err: e instanceof Error ? e.message : String(e) }, "DPCO analytics portfolio service unavailable, falling back to DB"); } - const rows = await q("SELECT dpco_org_id, COUNT(*) as total_clients, SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active_clients FROM dpco_clients GROUP BY dpco_org_id", []); - return { dpcos: rows, source: "db-fallback" }; - }), + .query(() => requestAnalyticsService("/api/dpco/analytics/portfolio")), analyticsHeatmap: protectedProcedure - .query(async () => { - try { - const res = await fetch(`http://localhost:${process.env.DPCO_ANALYTICS_PORT ?? 8330}/api/dpco/analytics/heatmap`, { signal: AbortSignal.timeout(5000) }); - if (res.ok) return res.json(); - } catch (e: unknown) { logger.debug({ err: e instanceof Error ? e.message : String(e) }, "DPCO analytics heatmap service unavailable, falling back to DB"); } - const rows = await q("SELECT TO_CHAR(created_at, 'YYYY-MM-DD') as date, COUNT(*) as count FROM dpco_audit_engagements WHERE created_at >= NOW() - INTERVAL '365 days' GROUP BY date ORDER BY date", []); - return { heatmap: rows, days: rows.length, source: "db-fallback" }; - }), + .query(() => requestAnalyticsService("/api/dpco/analytics/heatmap")), listNotifications: protectedProcedure .input(z.object({ limit: z.number().int().min(1).max(200).default(50), severity: z.string().optional() }).optional()) @@ -1042,10 +1068,14 @@ export const dpcoRouter = router({ `INSERT INTO dpco_audit_control_ratings (engagement_id, dpco_org_id, control_id, control_ref, control_title, rating, notes, rated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) - ON DUPLICATE KEY UPDATE - rating = VALUES(rating), - notes = VALUES(notes), - rated_by = VALUES(rated_by), + ON CONFLICT (engagement_id, control_id) + DO UPDATE SET + dpco_org_id = EXCLUDED.dpco_org_id, + control_ref = EXCLUDED.control_ref, + control_title = EXCLUDED.control_title, + rating = EXCLUDED.rating, + notes = EXCLUDED.notes, + rated_by = EXCLUDED.rated_by, updated_at = NOW()`, [ input.engagementId, @@ -1088,7 +1118,7 @@ export const dpcoRouter = router({ FROM dpco_client_policies p LEFT JOIN dpco_clients c ON c.id = p.client_id WHERE ${conditions.join(" AND ")} - ORDER BY p.assigned_at DESC`, + ORDER BY p.created_at DESC`, params ); return { policies: rows }; @@ -1103,11 +1133,16 @@ export const dpcoRouter = router({ notes: z.string().optional(), })) .mutation(async ({ input, ctx }) => { + const assignedBy = ctx.user?.name?.trim(); + if (!assignedBy) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "A persisted authenticated user is required to assign a client policy" }); + } await q( - `INSERT INTO dpco_client_policies (dpco_org_id, client_id, template_id, template_title, notes, assigned_by, assigned_at) - VALUES (?, ?, ?, ?, ?, ?, NOW()) - ON DUPLICATE KEY UPDATE template_title = VALUES(template_title), notes = VALUES(notes), updated_at = NOW()`, - [input.dpcoOrgId, input.clientId, input.templateId, input.templateTitle, input.notes ?? null, ctx.user?.name ?? "DPCO User"] + `INSERT INTO dpco_client_policies (dpco_org_id, client_id, template_id, template_name, notes, assigned_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (dpco_org_id, client_id, template_id) + DO UPDATE SET template_name = EXCLUDED.template_name, notes = EXCLUDED.notes, assigned_by = EXCLUDED.assigned_by, updated_at = NOW()`, + [input.dpcoOrgId, input.clientId, input.templateId, input.templateTitle, input.notes ?? null, assignedBy] ); const [row] = await q(`SELECT * FROM dpco_client_policies WHERE dpco_org_id = ? AND client_id = ? AND template_id = ?`, [input.dpcoOrgId, input.clientId, input.templateId]); emitMutationEvent("ndsep.dpco.mutation", { action: "dpco", ts: new Date().toISOString() }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); @@ -1117,7 +1152,7 @@ export const dpcoRouter = router({ updateClientPolicyStatus: protectedProcedure .input(z.object({ id: z.number().int(), - status: z.enum(["draft", "customised", "reviewed", "signed", "delivered"]), + status: z.enum(["draft", "customised", "reviewed", "signed", "delivered", "expired"]), notes: z.string().optional(), })) .mutation(async ({ input }) => { diff --git a/server/routers/mlFoundation.ts b/server/routers/mlFoundation.ts new file mode 100644 index 00000000..df4a4427 --- /dev/null +++ b/server/routers/mlFoundation.ts @@ -0,0 +1,56 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure, router } from "../_core/trpc"; + +const ML_FOUNDATION_URL = process.env.ML_FOUNDATION_URL ?? "http://127.0.0.1:8251"; + +async function modelRequest(path: string, options?: RequestInit): Promise { + let response: Response; + try { + response = await fetch(`${ML_FOUNDATION_URL}${path}`, { + ...options, + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Candidate ML service is unavailable; no heuristic or synthetic fallback was used.", + cause: error, + }); + } + if (!response.ok) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Verified candidate model is unavailable; inference was not performed.", + }); + } + return response.json(); +} + +const eventFeatureSchema = z.object({ + amount_ngn_equivalent: z.number().min(0).max(10_000_000), + velocity_24h: z.number().int().min(0).max(10_000), + device_age_days: z.number().int().min(0).max(20_000), + country_risk_band: z.number().int().min(0).max(2), + cross_border: z.boolean(), + prior_review_count: z.number().int().min(0).max(10_000), + failed_auth_24h: z.number().int().min(0).max(10_000), + unusual_hour: z.number().int().min(0).max(1), +}); + +/** + * Candidate-only ML access boundary. This router deliberately does not expose a + * deployment, promotion or automated-enforcement operation. It is protected by + * the platform session and always asks the worker for decision-support output. + */ +export const mlFoundationRouter = router({ + health: protectedProcedure.query(() => modelRequest("/health")), + models: protectedProcedure.query(() => modelRequest("/v1/models")), + predictSyntheticCandidate: protectedProcedure + .input(eventFeatureSchema) + .query(({ input }) => modelRequest("/v1/predict/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...input, purpose: "decision_support" }), + })), +}); diff --git a/server/routers/phase7Features.ts b/server/routers/phase7Features.ts index 8f616a79..d8b4376b 100644 --- a/server/routers/phase7Features.ts +++ b/server/routers/phase7Features.ts @@ -14,12 +14,12 @@ import { logger } from "../logger"; // ── Helper: execute raw SQL ─────────────────────────────────────────────────── async function exec(rawSql: string, params?: unknown[]): Promise[]> { const db = await getDb(); - if (!db) return []; + if (!db) throw new Error("Database unavailable for phase 7 feature query"); let result: unknown; if (params && params.length > 0) { const { getPool } = await import("../db"); const pool = getPool(); - if (!pool) return []; + if (!pool) throw new Error("PostgreSQL pool unavailable for phase 7 feature query"); const r = await pool.query(rawSql, params); return autoDecryptRows(rawSql, (r.rows ?? []) as Record[]); } @@ -28,33 +28,12 @@ async function exec(rawSql: string, params?: unknown[]): Promise[]); } -// ─── Ensure changelogs table exists ────────────────────────────────────────── -(async () => { - try { - const { getPool } = await import("../db"); - const pool = getPool(); - if (pool) { - await pool.query(`CREATE TABLE IF NOT EXISTS changelogs ( - id SERIAL PRIMARY KEY, - version VARCHAR(50) NOT NULL, - title VARCHAR(255) NOT NULL, - body TEXT, - category VARCHAR(50) DEFAULT 'feature', - published_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - )`); - } - } catch { - // The bootstrap is retried on the next process start; route handlers surface database failures to callers. - } -})(); - // ─── Changelog Router ────────────────────────────────────────────────────────── export const changelogRouter = router({ list: publicProcedure .input( z.object({ - limit: z.number().min(1).max(50).default(10), + limit: z.number().int().min(1).max(50).default(10), }).optional() ) .query(async ({ input }) => { @@ -63,7 +42,8 @@ export const changelogRouter = router({ `SELECT id, version, title, body, category, published_at FROM changelogs ORDER BY published_at DESC - LIMIT ${limit}` + LIMIT $1`, + [limit] ); return rows as Array<{ id: number; @@ -78,13 +58,12 @@ export const changelogRouter = router({ markSeen: protectedProcedure .input(z.object({ version: z.string() })) .mutation(async ({ ctx, input }) => { - const userId = ctx.user.openId.replace(/'/g, "''"); - const version = input.version.replace(/'/g, "''"); await exec( `INSERT INTO theme_preferences (user_id, last_seen_changelog_version, updated_at) - VALUES ('${userId}', '${version}', NOW()) + VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE - SET last_seen_changelog_version = '${version}', updated_at = NOW()` + SET last_seen_changelog_version = EXCLUDED.last_seen_changelog_version, updated_at = NOW()`, + [ctx.user.openId, input.version] ); emitMutationEvent(EVENTS.COMPLIANCE_GAP_IDENTIFIED, { action: "compliance_assessment", ts: new Date().toISOString() }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); return { ok: true }; @@ -98,35 +77,35 @@ export const sparklineRouter = router({ z.object({ orgId: z.string().optional(), sector: z.string().optional(), - days: z.number().min(7).max(90).default(30), + days: z.number().int().min(7).max(90).default(30), }) ) .query(async ({ input }) => { const { orgId, sector, days } = input; if (orgId) { - const safeOrgId = orgId.replace(/'/g, "''"); const rows = await exec( `SELECT score, recorded_at, sector FROM compliance_score_history - WHERE org_id = '${safeOrgId}' - AND recorded_at >= NOW() - INTERVAL '${days} days' + WHERE org_id = $1 + AND recorded_at >= NOW() - ($2 * INTERVAL '1 day') ORDER BY recorded_at ASC - LIMIT ${days}` + LIMIT $2`, + [orgId, days] ); return rows as Array<{ score: number; recorded_at: string; sector: string }>; } if (sector) { - const safeSector = sector.replace(/'/g, "''"); const rows = await exec( `SELECT AVG(score)::numeric(5,2) AS score, DATE(recorded_at)::text AS recorded_at, sector FROM compliance_score_history - WHERE sector = '${safeSector}' - AND recorded_at >= NOW() - INTERVAL '${days} days' + WHERE sector = $1 + AND recorded_at >= NOW() - ($2 * INTERVAL '1 day') GROUP BY DATE(recorded_at), sector ORDER BY recorded_at ASC - LIMIT ${days}` + LIMIT $2`, + [sector, days] ); return rows as Array<{ score: number; recorded_at: string; sector: string }>; } @@ -135,10 +114,11 @@ export const sparklineRouter = router({ const rows = await exec( `SELECT AVG(score)::numeric(5,2) AS score, DATE(recorded_at)::text AS recorded_at, 'ALL' AS sector FROM compliance_score_history - WHERE recorded_at >= NOW() - INTERVAL '${days} days' + WHERE recorded_at >= NOW() - ($1 * INTERVAL '1 day') GROUP BY DATE(recorded_at) ORDER BY recorded_at ASC - LIMIT ${days}` + LIMIT $1`, + [days] ); return rows as Array<{ score: number; recorded_at: string; sector: string }>; }), @@ -181,7 +161,11 @@ export const themePrefsRouter = router({ theme: (row?.theme as "light" | "dark") ?? "light", lastSeenChangelogVersion: (row?.last_seen_changelog_version as string) ?? null, }; - } catch { + } catch (error) { + logger.warn( + { err: error instanceof Error ? error.message : String(error), userId }, + "Unable to load durable theme preference; using the client default without persisting it" + ); return { theme: "light" as const, lastSeenChangelogVersion: null }; } }), @@ -194,16 +178,12 @@ export const themePrefsRouter = router({ ) .mutation(async ({ ctx, input }) => { const userId = ctx.user.openId; - try { - await exec( - `INSERT INTO theme_preferences (user_id, theme, updated_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (user_id) DO UPDATE SET theme = $2, updated_at = NOW()`, - [userId, input.theme] - ); - } catch { - // Silently ignore if table doesn't exist yet - } + await exec( + `INSERT INTO theme_preferences (user_id, theme, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, updated_at = NOW()`, + [userId, input.theme] + ); emitMutationEvent(EVENTS.COMPLIANCE_GAP_IDENTIFIED, { action: "compliance_assessment", ts: new Date().toISOString() }).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); return { ok: true }; }), diff --git a/server/security/alertmanagerConfigRenderer.test.ts b/server/security/alertmanagerConfigRenderer.test.ts new file mode 100644 index 00000000..90cedb3a --- /dev/null +++ b/server/security/alertmanagerConfigRenderer.test.ts @@ -0,0 +1,91 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, "../.."); +const rendererPath = resolve( + root, + "scripts/security/render-alertmanager-config.mjs" +); +const templatePath = resolve(root, "infra/prometheus/alertmanager.yml"); +const composePath = resolve(root, "docker-compose.production.yml"); +const makefilePath = resolve(root, "Makefile"); +const gitignorePath = resolve(root, ".gitignore"); + +const approvedEnvironment = [ + "SMTP_HOST=smtp.operations.ndsep.gov.ng:587", + "SMTP_FROM=ndsep-alerts@operations.ndsep.gov.ng", + "SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnopqrstuvwxyz", + "PAGERDUTY_INTEGRATION_KEY=0123456789abcdef0123456789abcdef", + "NITDA_COMPLIANCE_EMAIL=compliance@operations.ndsep.gov.ng", + "PLATFORM_URL=https://ndsep.operations.ndsep.gov.ng", +].join("\n"); + +function render(environment: string): { content: string; mode: number } { + const directory = mkdtempSync( + resolve(tmpdir(), "ndsep-alertmanager-render-") + ); + const environmentPath = resolve(directory, "production.env"); + const outputPath = resolve(directory, "alertmanager.yml"); + try { + writeFileSync(environmentPath, `${environment}\n`, "utf8"); + execFileSync( + process.execPath, + [rendererPath, templatePath, outputPath, environmentPath], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ); + return { + content: readFileSync(outputPath, "utf8"), + mode: statSync(outputPath).mode & 0o777, + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("production Alertmanager configuration renderer", () => { + it("renders all required approved values without fallback interpolation and protects the output file", () => { + const rendered = render(approvedEnvironment); + expect(rendered.content).toContain("smtp.operations.ndsep.gov.ng:587"); + expect(rendered.content).toContain( + "https://ndsep.operations.ndsep.gov.ng/compliance" + ); + expect(rendered.content).toContain("0123456789abcdef0123456789abcdef"); + expect(rendered.content).not.toMatch( + /__NDSEP_[A-Z0-9_]+__|PLACEHOLDER|\$\{[^}]+:-[^}]*\}/ + ); + expect(rendered.mode).toBe(0o600); + }); + + it("rejects placeholder secrets and alert-routing values", () => { + expect(() => + render( + approvedEnvironment.replace( + "PAGERDUTY_INTEGRATION_KEY=0123456789abcdef0123456789abcdef", + "PAGERDUTY_INTEGRATION_KEY=CHANGE_ME" + ) + ) + ).toThrow(/approved non-placeholder production value/); + }); + + it("requires the rendered file in production Compose and renders it before startup", () => { + expect(readFileSync(composePath, "utf8")).toContain( + "./.ndsep-runtime/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro" + ); + expect(readFileSync(makefilePath, "utf8")).toContain( + "render-alertmanager-config.mjs" + ); + expect(readFileSync(gitignorePath, "utf8")).toContain(".ndsep-runtime/"); + }); +}); diff --git a/server/security/apisixDurableRouteRegistry.config.test.ts b/server/security/apisixDurableRouteRegistry.config.test.ts new file mode 100644 index 00000000..1daffcd7 --- /dev/null +++ b/server/security/apisixDurableRouteRegistry.config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("APISIX durable route registry source contract", () => { + it("registers DDL-only migration 0041 and reconciles the Drizzle schema", () => { + const migration = read("drizzle/0041_apisix_durable_route_registry.sql"); + const journal = JSON.parse(read("drizzle/meta/_journal.json")) as { entries: Array<{ idx: number; tag: string }> }; + const schema = read("drizzle/schema.ts"); + expect(migration).toContain("CREATE TABLE gateway_routes"); + expect(migration).toContain("CREATE TABLE gateway_route_sync_attempts"); + expect(migration).not.toMatch(/^\s*(INSERT|UPDATE|DELETE|COPY)\b/im); + expect(journal.entries).toContainEqual(expect.objectContaining({ idx: 41, tag: "0041_apisix_durable_route_registry" })); + expect(schema).toContain('pgTable("gatewayRoutes"'.replace("gatewayRoutes", "gateway_routes")); + expect(schema).toContain('pgTable("gateway_route_sync_attempts"'); + }); + + it("loads active routes from PostgreSQL and persists every APISIX synchronization outcome", () => { + const worker = read("workers/go/cmd/apisix_manager/main.go"); + expect(worker).toContain("FROM gateway_routes"); + expect(worker).toContain("gateway_route_sync_attempts"); + expect(worker).toContain("no active gateway_routes are configured"); + expect(worker).toContain('recordSyncAttempt(ctx, route, "failed"'); + expect(worker).toContain('recordSyncAttempt(ctx, route, "succeeded"'); + expect(worker).toContain("APISIX accepted route but synchronization evidence was not persisted"); + expect(worker).not.toContain("NdsepRoutes"); + expect(worker).not.toContain('"degraded": true'); + }); + + it("requires internal authorization and explicit APISIX/PostgreSQL configuration", () => { + const worker = read("workers/go/cmd/apisix_manager/main.go"); + const productionCompose = read("docker-compose.production.yml"); + const middlewareCompose = read("docker-compose.middleware.yml"); + expect(worker).toContain("subtle.ConstantTimeCompare"); + expect(worker).toContain('requiredEnv("APISIX_MANAGER_INTERNAL_AUTH_TOKEN")'); + expect(worker).toContain("WORKER_DATABASE_URL or DATABASE_URL is required"); + expect(productionCompose).toContain("APISIX_MANAGER_INTERNAL_AUTH_TOKEN"); + expect(middlewareCompose).toContain("APISIX_MANAGER_INTERNAL_AUTH_TOKEN"); + }); + + it("does not register the legacy compiled-route gateway as an active worker", () => { + const workerManager = read("server/workerManager.ts"); + expect(workerManager).toContain("superseded by `apisix-manager`"); + expect(workerManager).not.toContain('id: "api-gateway"'); + expect(workerManager).toContain('id: "apisix-manager"'); + expect(workerManager).toContain("APISIX_MANAGER_PORT"); + }); +}); diff --git a/server/security/breakGlassEmergencyRelease.test.ts b/server/security/breakGlassEmergencyRelease.test.ts new file mode 100644 index 00000000..019e321e --- /dev/null +++ b/server/security/breakGlassEmergencyRelease.test.ts @@ -0,0 +1,245 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildAuditEvents, + generateBreakGlassEvidence, +} from "../../scripts/ci/generate-break-glass-evidence.mjs"; +import { + parseApprovalComment, + readAuthorizationPolicy, + sha256, + validateAuthorizationRecord, + verifyBreakGlassAuthorization, + verifyIndependentApprovals, +} from "../../scripts/ci/verify-break-glass-authorization.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const repository = "munisp/ndsep"; +const commit = "b".repeat(40); +const digest = `sha256:${"a".repeat(64)}`; +const now = new Date("2026-09-01T01:00:00.000Z"); + +function enabledPolicy() { + return { + schemaVersion: "ndsep.break-glass-authorization-policy.v1", + policyId: "ndsep-production-break-glass", + repository, + environment: "production-release", + enabled: true, + maximumAuthorizationMinutes: 60, + requiredIssueLabels: ["incident", "break-glass"], + roles: { + incident_commander: { allowedLogins: ["incident-lead"] }, + release_authority: { allowedLogins: ["release-lead"] }, + database_authority: { allowedLogins: ["database-lead"] }, + }, + }; +} + +function authorization() { + return { + schemaVersion: "ndsep.break-glass-authorization.v1", + authorizationId: "BG-2026-ABCD", + incident: { + id: "INC-2026-SCHEMA-HOTFIX", + severity: "P1", + summarySha256: `sha256:${"c".repeat(64)}`, + }, + candidate: { + image: "ghcr.io/munisp/ndsep", + digest, + sourceCommit: commit, + }, + schemaDrift: { + workflowRunId: "123456", + expectedMismatch: "column", + baselineFingerprint: "d".repeat(32), + stagingFingerprint: "e".repeat(32), + queryPackSha256: `sha256:${"f".repeat(64)}`, + falsePositiveAnalysisSha256: `sha256:${"1".repeat(64)}`, + }, + scope: { + environment: "production-release", + operation: "emergency-security-hotfix", + services: ["api"], + notBefore: "2026-09-01T00:30:00.000Z", + expiresAt: "2026-09-01T01:30:00.000Z", + }, + hotfixAuthor: "hotfix-author", + }; +} + +function approvalComment(role: string, actor: string, verified: ReturnType) { + return { + id: `${role}-${actor}`, + body: `\nrole: ${role}\nauthorization_id: ${verified.authorization.authorizationId}\nauthorization_sha256: ${verified.authorizationSha256}\nexpires_at: ${verified.expiresAt.toISOString()}`, + user: { login: actor }, + created_at: "2026-09-01T01:01:00.000Z", + html_url: `https://github.com/${repository}/issues/99#issuecomment-${actor}`, + }; +} + +async function withTempDirectory(callback: (directory: string) => Promise | T): Promise { + const directory = mkdtempSync(resolve(tmpdir(), "ndsep-break-glass-")); + try { + return await callback(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("digest-bound break-glass emergency controls", () => { + it("fails closed while the committed policy has no authorized approvers", async () => { + await expect(readAuthorizationPolicy(resolve(root, ".github/security/break-glass-authorization-policy.json"))) + .rejects.toThrow(/disabled until accountable approvers are configured/); + }); + + it("requires a live, digest-bound authorization and three distinct independent approvers", () => { + const policy = enabledPolicy(); + const verified = validateAuthorizationRecord(authorization(), { repository, candidateDigest: digest, sourceCommit: commit }, policy, now); + const incidentApproval = approvalComment("incident_commander", "incident-lead", verified); + expect(parseApprovalComment(incidentApproval)).toMatchObject({ + role: "incident_commander", + authorization_id: verified.authorization.authorizationId, + authorization_sha256: verified.authorizationSha256, + expires_at: verified.expiresAt.toISOString(), + }); + const approvals = verifyIndependentApprovals([ + incidentApproval, + approvalComment("release_authority", "release-lead", verified), + approvalComment("database_authority", "database-lead", verified), + ], verified, policy, "dispatch-actor"); + expect(approvals.map(approval => approval.actor)).toEqual(["incident-lead", "release-lead", "database-lead"]); + expect(() => verifyIndependentApprovals([ + approvalComment("incident_commander", "incident-lead", verified), + approvalComment("release_authority", "incident-lead", verified), + approvalComment("database_authority", "database-lead", verified), + ], verified, policy, "dispatch-actor")).toThrow(/Missing independent release_authority approval/); + expect(() => validateAuthorizationRecord({ ...authorization(), candidate: { ...authorization().candidate, digest: `sha256:${"9".repeat(64)}` } }, + { repository, candidateDigest: digest, sourceCommit: commit }, policy, now)).toThrow(/candidate digest does not match/); + }); + + it("binds the issue authorization to the GitHub source-commit author and three read-only approval comments", async () => { + await withTempDirectory(async directory => { + const policyPath = resolve(directory, "policy.json"); + writeFileSync(policyPath, JSON.stringify(enabledPolicy())); + const verified = validateAuthorizationRecord(authorization(), { repository, candidateDigest: digest, sourceCommit: commit }, enabledPolicy(), now); + const issueBody = `\n\`\`\`json\n${JSON.stringify(authorization())}\n\`\`\``; + const comments = [ + approvalComment("incident_commander", "incident-lead", verified), + approvalComment("release_authority", "release-lead", verified), + approvalComment("database_authority", "database-lead", verified), + ]; + const calls: string[] = []; + const fetchImpl = async (url: string) => { + calls.push(url); + if (url.includes(`/issues/99/comments`)) return { ok: true, status: 200, statusText: "OK", json: async () => comments }; + if (url.includes(`/issues/99`)) return { ok: true, status: 200, statusText: "OK", json: async () => ({ body: issueBody, labels: [{ name: "incident" }, { name: "break-glass" }], html_url: `https://github.com/${repository}/issues/99` }) }; + if (url.includes(`/commits/${commit}`)) return { ok: true, status: 200, statusText: "OK", json: async () => ({ author: { login: "hotfix-author" } }) }; + throw new Error(`Unexpected URL ${url}`); + }; + const result = await verifyBreakGlassAuthorization({ + issue: "99", repository, "candidate-digest": digest, "source-commit": commit, + "dispatch-actor": "dispatch-actor", policy: policyPath, "out-dir": resolve(directory, "out"), + now: now.toISOString(), "github-api-url": "https://api.github.com", + }, { token: "test-token", fetchImpl }); + expect(result.output.independentApprovals).toHaveLength(3); + expect(calls).toHaveLength(3); + expect(readFileSync(result.outputPath, "utf8")).toContain("INC-2026-SCHEMA-HOTFIX"); + }); + }); + + it("rejects expired authorizations and builds a chained audit ledger", () => { + const policy = enabledPolicy(); + expect(() => validateAuthorizationRecord(authorization(), { repository, candidateDigest: digest, sourceCommit: commit }, policy, + new Date("2026-09-01T01:31:00.000Z"))).toThrow(/not currently active/); + const ledger = buildAuditEvents({ + generatedAt: now.toISOString(), + workflow: { runId: 42, actor: "dispatch-actor", workflowRef: "https://github.com/munisp/ndsep/actions/workflows/digest-bound-emergency-release.yml@refs/heads/production" }, + candidate: { image: "ghcr.io/munisp/ndsep", digest, sourceCommit: commit }, + authorization: { id: "BG-2026-ABCD", sha256: `sha256:${"2".repeat(64)}`, incidentId: "INC-2026-SCHEMA-HOTFIX", expiresAt: "2026-09-01T01:30:00.000Z", scopeServices: ["api"], approvalActors: ["database-lead", "incident-lead", "release-lead"] }, + releaseEvidence: { sha256: `sha256:${"3".repeat(64)}`, highCriticalCount: 0, sbomSha256: `sha256:${"4".repeat(64)}` }, + cosignVerification: { sha256: `sha256:${"5".repeat(64)}` }, + provenanceVerification: { sha256: `sha256:${"6".repeat(64)}` }, + }); + expect(ledger.rows).toHaveLength(4); + expect(ledger.rows[0].previousEventHash).toBe(`sha256:${"0".repeat(64)}`); + expect(ledger.rows[1].previousEventHash).toBe(ledger.rows[0].eventHash); + expect(ledger.rootHash).toBe(ledger.rows[3].eventHash); + }); + + it("writes a digest-bound evidence manifest and hashed source artifacts", async () => { + await withTempDirectory(async directory => { + const authorizationVerification = { + schemaVersion: "ndsep.break-glass-authorization-verification.v1", + repository, + authorization: { + id: "BG-2026-ABCD", sha256: `sha256:${"2".repeat(64)}`, incidentId: "INC-2026-SCHEMA-HOTFIX", + candidate: { image: "ghcr.io/munisp/ndsep", digest, sourceCommit: commit }, + scope: { services: ["api"] }, expiresAt: "2026-09-01T01:30:00.000Z", + }, + independentApprovals: [ + { role: "incident_commander", actor: "incident-lead" }, + { role: "release_authority", actor: "release-lead" }, + { role: "database_authority", actor: "database-lead" }, + ], + noReadinessCredit: true, + }; + const releaseEvidence = { + schema: "ndsep.release-evidence.v1", + image: { name: "ghcr.io/munisp/ndsep", digest }, + source_sha: commit, + trivy: { high_critical_count: 0 }, + sbom: { sha256: `sha256:${"3".repeat(64)}` }, + }; + const authorizationPath = resolve(directory, "authorization.json"); + const releasePath = resolve(directory, "release.json"); + const cosignPath = resolve(directory, "cosign.json"); + const provenancePath = resolve(directory, "provenance.txt"); + writeFileSync(authorizationPath, JSON.stringify(authorizationVerification)); + writeFileSync(releasePath, JSON.stringify(releaseEvidence)); + writeFileSync(cosignPath, "[{\"critical\":{}}]\n"); + writeFileSync(provenancePath, "Verified attestation\n"); + const output = resolve(directory, "out"); + const result = await generateBreakGlassEvidence({ + "authorization-verification": authorizationPath, + "release-evidence": releasePath, + "cosign-verification": cosignPath, + "provenance-verification": provenancePath, + repository, + "source-commit": commit, + "candidate-digest": digest, + "run-id": "42", + actor: "dispatch-actor", + "workflow-ref": "https://github.com/munisp/ndsep/actions/workflows/digest-bound-emergency-release.yml@refs/heads/production", + "generated-at": now.toISOString(), + "out-dir": output, + }); + expect(result.manifest.integrity.attestationRequired).toBe(true); + expect(result.manifest.artifacts.auditEvents.count).toBe(4); + expect(readFileSync(result.rootPath, "utf8")).toContain("break-glass-evidence-manifest.json"); + expect(sha256(JSON.stringify(authorizationVerification))).toBe(result.manifest.authorization.verificationSha256); + expect(readFileSync(result.auditPath, "utf8").trim().split("\n")).toHaveLength(4); + }); + }); + + it("keeps preflight and authorization workflows manual, protected, and non-deploying", () => { + const authorizationWorkflow = readFileSync(resolve(root, ".github/workflows/digest-bound-emergency-release.yml"), "utf8"); + const preflightWorkflow = readFileSync(resolve(root, ".github/workflows/emergency-candidate-preflight.yml"), "utf8"); + expect(authorizationWorkflow).toContain("workflow_dispatch:"); + expect(authorizationWorkflow).toContain("AUTHORIZE_DIGEST_BOUND_BREAK_GLASS"); + expect(authorizationWorkflow).toContain("name: production-release"); + expect(authorizationWorkflow).toContain("issues: read"); + expect(authorizationWorkflow).toContain("packages: read"); + expect(authorizationWorkflow).toContain("verify-break-glass-authorization.mjs"); + expect(authorizationWorkflow).toContain("generate-break-glass-evidence.mjs"); + expect(authorizationWorkflow).toContain("actions/attest@0fae56888724ae3bba68e14ff82712d5c3d8e1f0"); + expect(authorizationWorkflow).toContain("Deployment performed: **no**"); + expect(authorizationWorkflow).not.toMatch(/git push|force push|--admin|bypass/i); + expect(preflightWorkflow).toContain("PREPARE_SIGNED_EMERGENCY_CANDIDATE"); + expect(preflightWorkflow).toContain("deploymentPerformed:false"); + expect(preflightWorkflow).toContain("verify-release-image-evidence.sh"); + }); +}); diff --git a/server/security/breakGlassSiemPagerIntegration.test.ts b/server/security/breakGlassSiemPagerIntegration.test.ts new file mode 100644 index 00000000..dfe077be --- /dev/null +++ b/server/security/breakGlassSiemPagerIntegration.test.ts @@ -0,0 +1,313 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateBreakGlassEvidence } from "../../scripts/ci/generate-break-glass-evidence.mjs"; +import { publishBreakGlassAlerts } from "../../scripts/ci/publish-break-glass-alerts.mjs"; +import { publishBreakGlassTamperAlert } from "../../scripts/ci/publish-break-glass-tamper-alert.mjs"; +import { verifyBreakGlassEvidence } from "../../scripts/ci/verify-break-glass-evidence.mjs"; +import { sha256 } from "../../scripts/ci/verify-break-glass-authorization.mjs"; + +const repository = "munisp/ndsep"; +const commit = "b".repeat(40); +const digest = `sha256:${"a".repeat(64)}`; +const generatedAt = "2026-09-01T01:00:00.000Z"; +const originalEnvironment = { ...process.env }; +const sourceRoot = resolve(import.meta.dirname, "../.."); + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnvironment)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnvironment)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function rewriteOuterBundleHashes(evidenceDirectory: string): void { + const auditPath = resolve(evidenceDirectory, "break-glass-audit-events.ndjson"); + const manifestPath = resolve(evidenceDirectory, "break-glass-evidence-manifest.json"); + const rootPath = resolve(evidenceDirectory, "break-glass-evidence-root.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const root = JSON.parse(readFileSync(rootPath, "utf8")); + const auditContents = readFileSync(auditPath); + manifest.artifacts.auditEvents.sha256 = sha256(auditContents); + root.audit.sha256 = manifest.artifacts.auditEvents.sha256; + writeFileSync(manifestPath, JSON.stringify(manifest)); + root.manifest.sha256 = sha256(readFileSync(manifestPath)); + writeFileSync(rootPath, JSON.stringify(root)); +} + +async function withEvidence(callback: (evidenceDirectory: string, outputDirectory: string) => Promise): Promise { + const directory = mkdtempSync(resolve(tmpdir(), "ndsep-break-glass-siem-")); + try { + const authorizationVerification = { + schemaVersion: "ndsep.break-glass-authorization-verification.v1", + repository, + authorization: { + id: "BG-2026-ABCD", sha256: `sha256:${"2".repeat(64)}`, incidentId: "INC-2026-SCHEMA-HOTFIX", + candidate: { image: "ghcr.io/munisp/ndsep", digest, sourceCommit: commit }, + scope: { services: ["api"] }, expiresAt: "2026-09-01T01:30:00.000Z", + }, + independentApprovals: [ + { role: "incident_commander", actor: "incident-lead" }, + { role: "release_authority", actor: "release-lead" }, + { role: "database_authority", actor: "database-lead" }, + ], + noReadinessCredit: true, + }; + const releaseEvidence = { + schema: "ndsep.release-evidence.v1", + image: { name: "ghcr.io/munisp/ndsep", digest }, + source_sha: commit, + trivy: { high_critical_count: 0 }, + sbom: { sha256: `sha256:${"3".repeat(64)}` }, + }; + const authorizationPath = resolve(directory, "authorization.json"); + const releasePath = resolve(directory, "release.json"); + const cosignPath = resolve(directory, "cosign.json"); + const provenancePath = resolve(directory, "provenance.txt"); + const evidenceDirectory = resolve(directory, "evidence"); + const outputDirectory = resolve(directory, "delivery"); + writeFileSync(authorizationPath, JSON.stringify(authorizationVerification)); + writeFileSync(releasePath, JSON.stringify(releaseEvidence)); + writeFileSync(cosignPath, "[{\"critical\":{}}]\n"); + writeFileSync(provenancePath, "Verified attestation\n"); + await generateBreakGlassEvidence({ + "authorization-verification": authorizationPath, + "release-evidence": releasePath, + "cosign-verification": cosignPath, + "provenance-verification": provenancePath, + repository, + "source-commit": commit, + "candidate-digest": digest, + "run-id": "42", + actor: "dispatch-actor", + "workflow-ref": "https://github.com/munisp/ndsep/actions/workflows/digest-bound-emergency-release.yml@refs/heads/production", + "generated-at": generatedAt, + "out-dir": evidenceDirectory, + }); + return await callback(evidenceDirectory, outputDirectory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("break-glass SIEM and pager evidence integration", () => { + it("verifies every copied artifact, root binding, and audit-chain link before disclosure", async () => { + await withEvidence(async evidenceDirectory => { + const verified = await verifyBreakGlassEvidence(evidenceDirectory); + expect(verified.candidate).toEqual({ image: "ghcr.io/munisp/ndsep", digest, sourceCommit: commit }); + expect(verified.verifiedEventTypes).toEqual([ + "break_glass.authorization_verified", + "break_glass.candidate_revalidated", + "break_glass.supply_chain_verified", + "break_glass.exception_consumed", + ]); + expect(verified.noReadinessCredit).toBe(true); + const auditPath = resolve(evidenceDirectory, "break-glass-audit-events.ndjson"); + writeFileSync(auditPath, `${readFileSync(auditPath, "utf8").replace("exception_consumed", "exception_bypassed")}`); + await expect(verifyBreakGlassEvidence(evidenceDirectory)).rejects.toThrow(/sha256 mismatch/); + }); + }); + + it("rejects a corrupted manifest before any artifact is disclosed", async () => { + await withEvidence(async evidenceDirectory => { + const manifestPath = resolve(evidenceDirectory, "break-glass-evidence-manifest.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.workflow.actor = "tampered-actor"; + writeFileSync(manifestPath, JSON.stringify(manifest)); + await expect(verifyBreakGlassEvidence(evidenceDirectory)).rejects.toThrow(/Break-glass root manifest hash mismatch/); + }); + }); + + it("rejects a changed manifest candidate even if an attacker recomputes its root manifest hash", async () => { + await withEvidence(async evidenceDirectory => { + const manifestPath = resolve(evidenceDirectory, "break-glass-evidence-manifest.json"); + const rootPath = resolve(evidenceDirectory, "break-glass-evidence-root.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const root = JSON.parse(readFileSync(rootPath, "utf8")); + manifest.candidate.digest = `sha256:${"8".repeat(64)}`; + writeFileSync(manifestPath, JSON.stringify(manifest)); + root.manifest.sha256 = sha256(readFileSync(manifestPath)); + writeFileSync(rootPath, JSON.stringify(root)); + await expect(verifyBreakGlassEvidence(evidenceDirectory)).rejects.toThrow(/Break-glass root candidate binding mismatch/); + }); + }); + + it("rejects an altered ledger event even if an attacker recomputes outer artifact hashes", async () => { + await withEvidence(async evidenceDirectory => { + const auditPath = resolve(evidenceDirectory, "break-glass-audit-events.ndjson"); + const newline = String.fromCharCode(10); + const rows = readFileSync(auditPath, "utf8").trim().split(newline).map(row => JSON.parse(row)); + rows[1].data.sbomSha256 = `sha256:${"9".repeat(64)}`; + writeFileSync(auditPath, `${rows.map(row => JSON.stringify(row)).join(newline)}${newline}`); + rewriteOuterBundleHashes(evidenceDirectory); + await expect(verifyBreakGlassEvidence(evidenceDirectory)).rejects.toThrow(/Audit event 2 hash mismatch/); + }); + }); + + it("rejects malformed audit JSON even if an attacker recomputes outer artifact hashes", async () => { + await withEvidence(async evidenceDirectory => { + const auditPath = resolve(evidenceDirectory, "break-glass-audit-events.ndjson"); + const newline = String.fromCharCode(10); + const lines = readFileSync(auditPath, "utf8").trim().split(newline); + lines[1] = "{malformed-audit-event"; + writeFileSync(auditPath, `${lines.join(newline)}${newline}`); + rewriteOuterBundleHashes(evidenceDirectory); + await expect(verifyBreakGlassEvidence(evidenceDirectory)).rejects.toThrow(/Break-glass audit event 2 is not valid JSON/); + }); + }); + + it("renders a sanitized dry-run delivery receipt without credentials or network access", async () => { + await withEvidence(async (evidenceDirectory, outputDirectory) => { + const result = await publishBreakGlassAlerts({ evidenceDir: evidenceDirectory, outDir: outputDirectory, deliver: false }); + expect(result.delivery.mode).toBe("dry-run"); + expect(result.delivery.siem.delivered).toBe(false); + expect(result.alert.severity).toBe("critical"); + expect(result.alert.deliveryScope).toMatch(/no deployment performed/); + const receipt = readFileSync(result.receiptPath, "utf8"); + expect(receipt).toContain("break-glass authorization evidence only"); + expect(receipt).not.toContain("test-token"); + }); + }); + + it("uses idempotent SIEM and PagerDuty payloads only after explicit confirmation", async () => { + await withEvidence(async (evidenceDirectory, outputDirectory) => { + const enabledPolicyPath = resolve(outputDirectory, "..", "enabled-alert-policy.json"); + writeFileSync(enabledPolicyPath, JSON.stringify({ + schemaVersion: "ndsep.break-glass-alert-delivery-policy.v1", + policyId: "ndsep-production-break-glass-alert-delivery", + repository, + environment: "production-release", + enabled: true, + allowedSiemModes: ["splunk-hec", "elastic"], + pagerDutyEndpoint: "https://events.pagerduty.com/v2/enqueue", + attestationRequired: true, + externalImmutableRetentionRequired: true, + noReadinessCredit: true, + })); + process.env.BREAK_GLASS_DELIVERY_CONFIRMATION = "DELIVER_SANITIZED_BREAK_GLASS_EVENTS"; + process.env.BREAK_GLASS_ALERT_DELIVERY_POLICY = enabledPolicyPath; + process.env.BREAK_GLASS_SIEM_MODE = "splunk-hec"; + process.env.BREAK_GLASS_SIEM_ENDPOINT = "https://siem.example/services/collector/event"; + process.env.BREAK_GLASS_SIEM_TOKEN = "splunk-token-not-written"; + process.env.BREAK_GLASS_SPLUNK_INDEX = "ndsep_security"; + process.env.PAGERDUTY_ROUTING_KEY = "A".repeat(32); + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return { ok: true, status: 202 }; + }; + const result = await publishBreakGlassAlerts({ evidenceDir: evidenceDirectory, outDir: outputDirectory, deliver: true }, { fetchImpl }); + expect(result.delivery.siem).toMatchObject({ configured: true, delivered: true, mode: "splunk-hec", status: 202 }); + expect(result.delivery.pagerDuty).toMatchObject({ configured: true, delivered: true, status: 202 }); + expect(calls.slice(0, 2).map(call => call.url)).toEqual([ + "https://siem.example/services/collector/event", + "https://events.pagerduty.com/v2/enqueue", + ]); + const splunkRequest = JSON.parse(String(calls[0].init.body)); + expect(splunkRequest.event.evidence.rootSha256).toMatch(/^sha256:/); + expect(splunkRequest.event.audit.noReadinessCredit).toBe(true); + const pagerRequest = JSON.parse(String(calls[1].init.body)); + expect(pagerRequest.event_action).toBe("trigger"); + expect(pagerRequest.dedup_key).toContain("BG-2026-ABCD"); + expect(JSON.stringify(pagerRequest)).not.toContain("splunk-token-not-written"); + + process.env.BREAK_GLASS_SIEM_MODE = "elastic"; + process.env.BREAK_GLASS_SIEM_ENDPOINT = "https://elastic.example"; + process.env.BREAK_GLASS_ELASTIC_INDEX = "ndsep-break-glass"; + const elasticOutputDirectory = resolve(outputDirectory, "..", "elastic-delivery"); + const elasticResult = await publishBreakGlassAlerts({ evidenceDir: evidenceDirectory, outDir: elasticOutputDirectory, deliver: true }, { fetchImpl }); + expect(elasticResult.delivery.siem).toMatchObject({ configured: true, delivered: true, mode: "elastic", status: 202 }); + expect(calls[2].url).toMatch(/^https:\/\/elastic\.example\/ndsep-break-glass\/_create\/[a-f0-9]{64}$/); + }); + }); + + it("renders and delivers a sanitized tamper-detection alert only through the reviewed policy", async () => { + await withEvidence(async (_evidenceDirectory, outputDirectory) => { + const common = { + repository, + sourceCommit: commit, + candidateDigest: digest, + runId: "42", + failureCode: "audit-event-hash-mismatch", + }; + const dryRun = await publishBreakGlassTamperAlert({ ...common, outDir: outputDirectory, deliver: false }); + expect(dryRun.receipt.mode).toBe("dry-run"); + expect(dryRun.alert.eventType).toBe("break_glass.evidence_tamper_detected"); + expect(dryRun.alert.deliveryScope).toMatch(/no deployment performed/); + const enabledPolicyPath = resolve(outputDirectory, "..", "enabled-tamper-alert-policy.json"); + writeFileSync(enabledPolicyPath, JSON.stringify({ + schemaVersion: "ndsep.break-glass-alert-delivery-policy.v1", + policyId: "ndsep-production-break-glass-alert-delivery", + repository, + environment: "production-release", + enabled: true, + allowedSiemModes: ["splunk-hec"], + pagerDutyEndpoint: "https://events.pagerduty.com/v2/enqueue", + attestationRequired: true, + externalImmutableRetentionRequired: true, + noReadinessCredit: true, + })); + process.env.BREAK_GLASS_DELIVERY_CONFIRMATION = "DELIVER_SANITIZED_BREAK_GLASS_EVENTS"; + process.env.BREAK_GLASS_ALERT_DELIVERY_POLICY = enabledPolicyPath; + process.env.BREAK_GLASS_SIEM_MODE = "splunk-hec"; + process.env.BREAK_GLASS_SIEM_ENDPOINT = "https://siem.example/services/collector/event"; + process.env.BREAK_GLASS_SIEM_TOKEN = "never-disclosed"; + process.env.PAGERDUTY_ROUTING_KEY = "B".repeat(32); + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return { ok: true, status: 202 }; + }; + const deliveryOutput = resolve(outputDirectory, "..", "tamper-delivery"); + const delivered = await publishBreakGlassTamperAlert({ ...common, outDir: deliveryOutput, deliver: true }, { fetchImpl }); + expect(delivered.receipt.siem).toMatchObject({ delivered: true, mode: "splunk-hec", status: 202 }); + expect(delivered.receipt.pagerDuty).toMatchObject({ delivered: true, status: 202 }); + expect(calls.map(call => call.url)).toEqual([ + "https://siem.example/services/collector/event", + "https://events.pagerduty.com/v2/enqueue", + ]); + const siemEvent = JSON.parse(String(calls[0].init.body)).event; + expect(siemEvent.tamperDetection.failureCode).toBe("audit-event-hash-mismatch"); + expect(JSON.stringify(siemEvent)).not.toContain("never-disclosed"); + }); + }); + + it("keeps source alert delivery disabled and evidence-verified until a reviewed policy change", () => { + const policy = JSON.parse(readFileSync(resolve(sourceRoot, ".github/security/break-glass-alert-delivery-policy.json"), "utf8")); + const workflow = readFileSync(resolve(sourceRoot, ".github/workflows/digest-bound-emergency-release.yml"), "utf8"); + const securityGate = readFileSync(resolve(sourceRoot, ".github/workflows/security-gate.yml"), "utf8"); + expect(policy.enabled).toBe(false); + expect(policy.attestationRequired).toBe(true); + expect(policy.externalImmutableRetentionRequired).toBe(true); + expect(workflow).toContain("gh attestation verify"); + expect(workflow).toContain("github-attestation-verify.txt"); + expect(workflow).toContain("verify-break-glass-evidence.mjs"); + expect(workflow).toContain("id: evidence_verify"); + expect(workflow).toContain("ledger-verification.log"); + expect(workflow).toContain("publish-break-glass-tamper-alert.mjs"); + expect(workflow).toContain("manifest-root-hash-mismatch"); + expect(workflow).toContain("manifest-candidate-binding-mismatch"); + expect(workflow).toContain("audit-event-hash-mismatch"); + expect(workflow).toContain("Fail closed after evidence tamper detection"); + expect(workflow).toContain("publish-break-glass-alerts.mjs"); + expect(workflow).toContain("DELIVER_SANITIZED_BREAK_GLASS_EVENTS"); + expect(workflow).toContain("BREAK_GLASS_SIEM_TOKEN"); + expect(workflow).toContain("PAGERDUTY_ROUTING_KEY"); + expect(securityGate).toContain("breakGlassSiemPagerIntegration.test.ts"); + }); + + it("refuses live delivery until the protected confirmation and valid provider inputs exist", async () => { + await withEvidence(async (evidenceDirectory, outputDirectory) => { + process.env.BREAK_GLASS_SIEM_MODE = "elastic"; + process.env.BREAK_GLASS_SIEM_ENDPOINT = "https://siem.example"; + process.env.BREAK_GLASS_SIEM_TOKEN = "unused"; + process.env.PAGERDUTY_ROUTING_KEY = "A".repeat(32); + await expect(publishBreakGlassAlerts({ evidenceDir: evidenceDirectory, outDir: outputDirectory, deliver: true })) + .rejects.toThrow(/BREAK_GLASS_DELIVERY_CONFIRMATION/); + }); + }); +}); diff --git a/server/security/durableAnalyticsPersistence.config.test.ts b/server/security/durableAnalyticsPersistence.config.test.ts new file mode 100644 index 00000000..07ba4d2d --- /dev/null +++ b/server/security/durableAnalyticsPersistence.config.test.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, "..", ".."); +const read = (relativePath: string) => readFileSync(resolve(root, relativePath), "utf8"); + +describe("durable DPCO analytics and outbox source contracts", () => { + it("registers PostgreSQL analytics and outbox tables in the active Drizzle journal", () => { + const journal = JSON.parse(read("drizzle/meta/_journal.json")) as { entries: Array<{ idx: number; tag: string }> }; + expect(journal.entries).toContainEqual(expect.objectContaining({ idx: 36, tag: "0036_dpco_analytics_durable_events" })); + const migration = read("drizzle/0036_dpco_analytics_durable_events.sql"); + expect(migration).toContain("CREATE TABLE IF NOT EXISTS dpco_analytics_events"); + expect(migration).toContain("CREATE TABLE IF NOT EXISTS dpco_analytics_dpco_stats"); + expect(migration).toContain("CREATE TABLE IF NOT EXISTS domain_event_outbox"); + expect(migration).toContain("uq_dpco_analytics_events_source_payload"); + expect(migration).toContain("idx_domain_event_outbox_due"); + }); + + it("uses PostgreSQL as the Python analytics source of truth without demo generation", () => { + const service = read("orchestration/python/dpco_analytics/service.py"); + expect(service).toContain("class DurableAnalyticsStore"); + expect(service).toContain("ConnectionPool("); + expect(service).toContain("INSERT INTO dpco_analytics_events"); + expect(service).toContain("INSERT INTO dpco_analytics_dpco_stats"); + expect(service).toContain("ON CONFLICT (source, payload_sha256) DO NOTHING"); + expect(service).toContain("auto_offset_reset=\"earliest\""); + expect(service).toContain("enable_auto_commit=False"); + expect(service).toContain("consumer.commit()"); + expect(service).not.toContain("_events:"); + expect(service).not.toContain("_dpco_stats:"); + expect(service).not.toContain("seed_demo"); + expect(service).toContain("no in-memory fallback"); + }); + + it("installs the analytics PostgreSQL client and requires deliberate runtime wiring", () => { + const dockerfile = read("orchestration/python/dpco_analytics/Dockerfile"); + const compose = read("orchestration/docker-compose.yml"); + expect(dockerfile).toContain('"psycopg[binary,pool]"'); + expect(compose).toContain("DPCO_ANALYTICS_DATABASE_URL: ${DPCO_ANALYTICS_DATABASE_URL:?DPCO_ANALYTICS_DATABASE_URL is required}"); + expect(compose).toContain("DPCO_ANALYTICS_SERVICE_TOKEN: ${DPCO_ANALYTICS_SERVICE_TOKEN:?DPCO_ANALYTICS_SERVICE_TOKEN is required}"); + }); + + it("keeps gateway analytics responses on the authenticated durable service path", () => { + const router = read("server/routers/dpco.ts"); + expect(router).toContain("DPCO_ANALYTICS_URL"); + expect(router).toContain("DPCO_ANALYTICS_SERVICE_TOKEN"); + expect(router).toContain('requestAnalyticsService("/api/dpco/analytics/trends")'); + expect(router).toContain('requestAnalyticsService("/api/dpco/analytics/portfolio")'); + expect(router).toContain('requestAnalyticsService("/api/dpco/analytics/heatmap")'); + expect(router).not.toContain('source: "db-fallback"'); + expect(router).toContain("DPCO analytics persistence service is unavailable"); + }); + + it("reconciles DPCO client-policy schema, workflow states, and PostgreSQL upserts", () => { + const journal = JSON.parse(read("drizzle/meta/_journal.json")) as { entries: Array<{ idx: number; tag: string }> }; + const migration = read("drizzle/0037_dpco_client_policy_postgres_reconciliation.sql"); + const router = read("server/routers/dpco.ts"); + const schema = read("drizzle/schema.ts"); + expect(journal.entries).toContainEqual(expect.objectContaining({ idx: 37, tag: "0037_dpco_client_policy_postgres_reconciliation" })); + expect(migration).toContain("uq_dpco_client_policies_natural_key"); + expect(migration).toContain("duplicate (dpco_org_id, client_id, template_id)"); + expect(migration).toContain("ADD VALUE IF NOT EXISTS 'customised'"); + expect(migration).toContain("ADD VALUE IF NOT EXISTS 'delivered'"); + expect(router).toContain("ON CONFLICT (dpco_org_id, client_id, template_id)"); + expect(router).toContain("template_name"); + expect(router).toContain("A persisted authenticated user is required"); + expect(router).not.toContain("ON DUPLICATE KEY"); + expect(router).not.toContain("assigned_at DESC"); + expect(schema).toContain('pgEnum("dpco_client_policy_status", ["draft", "customised", "reviewed", "signed", "delivered", "expired"])'); + }); + + it("reconciles audit-control rating metadata and PostgreSQL upsert semantics", () => { + const journal = JSON.parse(read("drizzle/meta/_journal.json")) as { entries: Array<{ idx: number; tag: string }> }; + const migration = read("drizzle/0038_dpco_audit_control_rating_postgres_reconciliation.sql"); + const router = read("server/routers/dpco.ts"); + const schema = read("drizzle/schema.ts"); + expect( + journal.entries.some( + entry => entry.idx === 38 && entry.tag === "0038_dpco_audit_control_rating_postgres_reconciliation", + ), + ).toBe(true); + expect(migration).toContain("uq_dpco_audit_control_ratings_natural_key"); + expect(migration).toContain("duplicate (engagement_id, control_id)"); + expect(router).toContain("ON CONFLICT (engagement_id, control_id)"); + expect(router).not.toContain("ON DUPLICATE KEY"); + expect(schema).toContain('dpcoOrgId: integer("dpco_org_id")'); + expect(schema).toContain('controlRef: varchar("control_ref", { length: 255 })'); + expect(schema).toContain('controlTitle: varchar("control_title", { length: 255 })'); + }); + + it("uses a PostgreSQL outbox rather than a process-local Kafka retry queue", () => { + const eventBus = read("server/eventBus.ts"); + const server = read("server/_core/index.ts"); + expect(eventBus).toContain("domain_event_outbox"); + expect(eventBus).toContain("FOR UPDATE SKIP LOCKED"); + expect(eventBus).toContain("startDurableOutbox"); + expect(eventBus).not.toContain("retryQueue"); + expect(server).toContain("startDurableOutbox();"); + expect(server).toContain("stopDurableOutbox();"); + }); + + it("requires production Keycloak and persisted user identity mapping", () => { + const validation = read("server/envValidation.ts"); + const middleware = read("server/authMiddleware.ts"); + expect(validation).toContain("production requires real Keycloak IAM"); + expect(middleware).toContain("Keycloak identity is not provisioned in NDSEP"); + expect(middleware).toContain("A valid Keycloak bearer token is required"); + expect(middleware).not.toContain("still set basic info"); + }); +}); diff --git a/server/security/e2eMatrixArtifactsVerifier.test.ts b/server/security/e2eMatrixArtifactsVerifier.test.ts new file mode 100644 index 00000000..107add19 --- /dev/null +++ b/server/security/e2eMatrixArtifactsVerifier.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import os from "node:os"; +import path from "node:path"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = path.resolve(import.meta.dirname, "../.."); +const verifier = path.join(repositoryRoot, "scripts/ci/verify-e2e-matrix-artifacts.mjs"); +const commit = "f328595c389a8ebbd794730442f52287fcea7158"; +const shards = [ + ["auth", 17], + ["critical-flows", 34], + ["dpco-onboarding", 20], + ["enforcement-loop", 24], + ["penalty-enforcement", 18], + ["temporal-kafka", 16], + ["critical-workflows", 15], + ["visual-regression", 12], +] as const; +const temporaryRoots: string[] = []; + +async function createCompleteFixture(options: { flakyShard?: string; omitShard?: string; manifestCommit?: string } = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), "ndsep-e2e-matrix-verifier-")); + temporaryRoots.push(root); + for (const [id, expected] of shards) { + if (id === options.omitShard) continue; + const name = `e2e-${commit}-${id}-chromium`; + const shardDirectory = path.join(root, name); + await mkdir(shardDirectory, { recursive: true }); + await writeFile( + path.join(shardDirectory, "e2e-shard-manifest.json"), + `${JSON.stringify({ + candidateCommit: options.manifestCommit ?? commit, + shard: `${id}-chromium`, + project: "chromium", + testRunId: `test-run-${id}`, + })}\n` + ); + await writeFile( + path.join(shardDirectory, "test-results.json"), + `${JSON.stringify({ + stats: { + expected, + unexpected: 0, + flaky: id === options.flakyShard ? 1 : 0, + skipped: 0, + duration: 2500, + }, + })}\n` + ); + } + return root; +} + +async function runVerifier(root: string) { + return execFileAsync(process.execPath, [verifier], { + cwd: repositoryRoot, + env: { + ...process.env, + E2E_ARTIFACT_ROOT: root, + E2E_CANDIDATE_COMMIT: commit, + E2E_MATRIX_SUMMARY_PATH: path.join(root, "e2e-matrix-evidence.json"), + }, + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +describe("E2E matrix artifact verifier", () => { + it("accepts all eight complete first-attempt Chromium shard artifacts", async () => { + const root = await createCompleteFixture(); + const { stdout } = await runVerifier(root); + const reported = JSON.parse(stdout) as { status: string; shardCount: number; totalExpectedTests: number }; + const evidence = JSON.parse(await readFile(path.join(root, "e2e-matrix-evidence.json"), "utf8")) as typeof reported; + + expect(reported).toMatchObject({ + status: "complete_without_retries", + shardCount: 8, + totalExpectedTests: 156, + }); + expect(evidence).toMatchObject(reported); + }); + + it("fails closed when a required shard artifact is absent", async () => { + const root = await createCompleteFixture({ omitShard: "temporal-kafka" }); + + await expect(runVerifier(root)).rejects.toMatchObject({ + stderr: expect.stringContaining("missing required shard artifact directory"), + }); + }); + + it("fails closed when a result reports a retry/flaky test", async () => { + const root = await createCompleteFixture({ flakyShard: "auth" }); + + await expect(runVerifier(root)).rejects.toMatchObject({ + stderr: expect.stringContaining("flaky/retried test"), + }); + }); + + it("fails closed when an artifact manifest is bound to another commit", async () => { + const root = await createCompleteFixture({ manifestCommit: "a".repeat(40) }); + + await expect(runVerifier(root)).rejects.toMatchObject({ + stderr: expect.stringContaining("manifest candidateCommit does not match"), + }); + }); +}); diff --git a/server/security/eventDeliveryDurability.config.test.ts b/server/security/eventDeliveryDurability.config.test.ts new file mode 100644 index 00000000..472a77fa --- /dev/null +++ b/server/security/eventDeliveryDurability.config.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("durable Kafka, Dapr, and Fluvio event-delivery source contract", () => { + it("does not mark a PostgreSQL outbox row published when required Dapr delivery rejects", () => { + const eventBus = read("server/eventBus.ts"); + const delivery = eventBus.slice(eventBus.indexOf("async function publishOutboxRow")); + expect(delivery).toContain('if (!kafkaOk) throw new Error("Kafka publish failed")'); + expect(delivery).toContain("await daprPublish(row.topic"); + expect(delivery).toContain("await markPublished(row.id)"); + expect(delivery.indexOf("await daprPublish(row.topic")).toBeLessThan(delivery.indexOf("await markPublished(row.id)")); + expect(delivery).not.toContain("Dapr side delivery failed"); + }); + + it("does not actively launch the legacy non-durable Go event bus", () => { + const workers = read("server/workerManager.ts"); + expect(workers).toContain("The legacy `event_bus` worker is intentionally not registered"); + expect(workers).not.toContain('id: "event-bus"'); + expect(workers).toContain("PostgreSQL transactional outbox owns event state"); + }); + + it("requires an explicit Fluvio destination and does not advertise Kafka fallback", () => { + const relay = read("workers/go/cmd/fluvio_relay/main.go"); + expect(relay).toContain("FLUVIO_ENDPOINT or FLUVIO_PRODUCE_URL is required"); + expect(relay).toContain("production Fluvio relay requires FLUVIO_PRODUCE_URL"); + expect(relay).toContain("FLUVIO_AUTH_TOKEN"); + expect(relay).not.toContain("KAFKA_FALLBACK"); + expect(relay).not.toContain("kafka_fallback"); + }); +}); diff --git a/server/security/extendedE2eMatrixWorkflow.config.test.ts b/server/security/extendedE2eMatrixWorkflow.config.test.ts new file mode 100644 index 00000000..9b7efbba --- /dev/null +++ b/server/security/extendedE2eMatrixWorkflow.config.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../.."); +const workflow = readFileSync(path.join(root, ".github/workflows/extended-e2e-matrix.yml"), "utf8"); +const verifier = readFileSync(path.join(root, "scripts/ci/verify-e2e-matrix-artifacts.mjs"), "utf8"); +const manifestWriter = readFileSync(path.join(root, "scripts/ci/write-e2e-shard-manifest.mjs"), "utf8"); +const visualSuite = readFileSync(path.join(root, "e2e/visual-regression.spec.ts"), "utf8"); + +describe("extended E2E matrix workflow contract", () => { + it("declares exactly the eight required first-attempt Chromium shards", () => { + const expected = [ + ["auth", "e2e/auth.spec.ts", 17], + ["critical-flows", "e2e/critical-flows.spec.ts", 34], + ["dpco-onboarding", "e2e/dpco-onboarding.spec.ts", 20], + ["enforcement-loop", "e2e/enforcement-loop.spec.ts", 24], + ["penalty-enforcement", "e2e/penalty-enforcement.spec.ts", 18], + ["temporal-kafka", "e2e/temporal-kafka.spec.ts", 16], + ["critical-workflows", "e2e/tests/critical-workflows.spec.ts", 15], + ["visual-regression", "e2e/visual-regression.spec.ts", 12], + ] as const; + + expect(workflow).toContain("fail-fast: false"); + expect(workflow.match(/^ {10}- id:/gm)).toHaveLength(8); + for (const [id, spec, testCount] of expected) { + expect(workflow).toContain(` - id: ${id}`); + expect(workflow).toContain(` spec: ${spec}`); + expect(workflow).toContain(` expected_tests: ${testCount}`); + expect(verifier).toContain(`{ id: "${id}", project: "chromium", expectedTests: ${testCount} }`); + } + }); + + it("requires a disposable PostgreSQL database and a unique candidate-bound artifact directory per shard", () => { + expect(workflow).toContain("image: postgres:16-alpine"); + expect(workflow).toContain("DATABASE_URL: postgresql://ndsep_user:ndsep_test_pass@127.0.0.1:5432/ndsep_test"); + expect(workflow).toContain("E2E_CANDIDATE_COMMIT: ${{ github.sha }}"); + expect(workflow).toContain("E2E_TEST_RUN_ID: ${{ github.run_id }}-${{ matrix.id }}-${{ matrix.project }}"); + expect(workflow).toContain("E2E_ARTIFACT_DIRECTORY: test-results/${{ matrix.id }}-${{ matrix.project }}"); + const testStep = workflow.indexOf("Run release-grade E2E shard without retry masking"); + const manifestStep = workflow.indexOf("Write candidate-bound shard manifest after Playwright output initialization"); + expect(testStep).toBeGreaterThan(-1); + expect(manifestStep).toBeGreaterThan(testStep); + expect(workflow.slice(manifestStep, manifestStep + 220)).toContain("if: always()"); + expect(workflow).toContain("node scripts/ci/write-e2e-shard-manifest.mjs"); + expect(manifestWriter).toContain("E2E_CANDIDATE_COMMIT must be a full 40-character Git commit SHA"); + expect(manifestWriter).toContain("candidateCommit"); + }); + + it("forbids release-tier retry masking and requires per-shard retained diagnostics", () => { + expect(workflow).toContain("--workers=1"); + expect(workflow).toContain("--retries=0"); + expect(workflow).toContain("--reporter=line,json"); + expect(workflow).toContain("Collect per-shard diagnostics in its evidence directory"); + expect(workflow).toContain("retention-days: 30"); + }); + + it("keeps visual checks bound to the isolated shard URL and never updates baselines automatically", () => { + expect(visualSuite).toContain('const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"'); + expect(visualSuite).toContain("const SNAPSHOT_PAGES = ["); + expect((visualSuite.match(/name: "/g) ?? [])).toHaveLength(10); + const visualMatrixEntry = workflow.slice(workflow.indexOf("- id: visual-regression"), workflow.indexOf("- name: Write candidate-bound shard manifest")); + expect(visualMatrixEntry).not.toContain("--update-snapshots"); + }); + + it("runs a mandatory artifact fan-in verifier that rejects missing or retried evidence", () => { + expect(workflow).toContain("verify-e2e-matrix:"); + expect(workflow).toContain("actions/download-artifact@v4"); + expect(workflow).toContain("node scripts/ci/verify-e2e-matrix-artifacts.mjs"); + expect(verifier).toContain("missing required shard artifact directory"); + expect(verifier).toContain("flaky/retried test"); + expect(verifier).toContain("status: \"complete_without_retries\""); + }); +}); diff --git a/server/security/isolatedVisualRegressionRunner.config.test.ts b/server/security/isolatedVisualRegressionRunner.config.test.ts new file mode 100644 index 00000000..27e538b4 --- /dev/null +++ b/server/security/isolatedVisualRegressionRunner.config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const script = () => + fs.readFileSync( + path.join(root, "scripts/ci/run-isolated-visual-regression.sh"), + "utf8" + ); + +describe("isolated visual regression runner source contract", () => { + it("uses a unique localhost PostgreSQL database and isolated output directory", () => { + const source = script(); + expect(source).toContain('role="ndsep_visual_${run_nonce}"'); + expect(source).toContain('database="ndsep_visual_${run_nonce}"'); + expect(source).toContain("@127.0.0.1:5432/${database}"); + expect(source).toContain("mktemp -d /tmp/ndsep-visual-regression.XXXXXX"); + expect(source).toContain( + "NDSEP_VISUAL_OUTPUT_DIR must be under /tmp or /home/ubuntu" + ); + expect(source).toContain("DROP DATABASE IF EXISTS ${database}"); + expect(source).toContain("DROP ROLE IF EXISTS ${role}"); + expect(source).toContain("git status --porcelain"); + expect(source).toContain( + 'worktree add --detach "$worktree" "$source_commit"' + ); + expect(source).toContain('worktree remove --force "$worktree"'); + expect(source).toContain('rm -rf "$worktree_parent"'); + }); + + it("runs only Chromium with one worker, zero retries, and no snapshot mutation", () => { + const source = script(); + const commandStart = source.indexOf( + "pnpm exec playwright test e2e/visual-regression.spec.ts" + ); + const commandEnd = source.indexOf("test_status=$?", commandStart); + const command = source.slice(commandStart, commandEnd); + expect(commandStart).toBeGreaterThan(-1); + expect(commandEnd).toBeGreaterThan(commandStart); + expect(command).toContain("--project=chromium"); + expect(command).toContain("--workers=1"); + expect(command).toContain("--retries=0"); + expect(command).not.toContain("--update-snapshots"); + expect(source).toContain("NDSEP_VISUAL_PORT=%s is already listening"); + expect(source).not.toContain("git add"); + expect(source).not.toContain("git commit"); + }); + + it("requires health before browser execution and retains JSON diagnostics outside the checkout", () => { + const source = script(); + expect(source).toContain('"$base_url/api/health"'); + expect(source).toContain( + 'PLAYWRIGHT_JSON_OUTPUT_NAME="$output_dir/test-results.json"' + ); + expect(source).toContain('--output="$output_dir/playwright-output"'); + expect(source).toContain("playwright-exit-status.txt"); + expect(source).toContain("diagnostic_output=%s"); + }); +}); diff --git a/server/security/keycloakPostgresFailClosed.config.test.ts b/server/security/keycloakPostgresFailClosed.config.test.ts new file mode 100644 index 00000000..20f88c55 --- /dev/null +++ b/server/security/keycloakPostgresFailClosed.config.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("Keycloak PostgreSQL and fail-closed source contract", () => { + it("does not start Keycloak with development-memory storage in active Compose profiles", () => { + for (const compose of ["docker-compose.production.yml", "docker-compose.middleware.yml", "orchestration/docker-compose.yml"]) { + const source = read(compose); + const start = source.indexOf(" keycloak:"); + expect(start).toBeGreaterThanOrEqual(0); + const remainder = source.slice(start); + const nextService = remainder.search(/\n {2}[A-Za-z0-9_-]+:/); + const keycloak = nextService === -1 ? remainder : remainder.slice(0, nextService); + expect(keycloak).toContain("KC_DB: postgres"); + expect(keycloak).toContain("KC_DB_URL"); + expect(keycloak).not.toContain("KC_DB: dev-mem"); + expect(keycloak).not.toContain("start-dev"); + } + }); + + it("requires explicit non-placeholder administrator and database inputs in the middleware profile", () => { + const middleware = read("docker-compose.middleware.yml"); + expect(middleware).toContain("KEYCLOAK_ADMIN_USER is required"); + expect(middleware).toContain("KEYCLOAK_ADMIN_PASSWORD is required"); + expect(middleware).toContain("KEYCLOAK_DB_URL is required"); + expect(middleware).toContain("KEYCLOAK_HOSTNAME is required"); + expect(middleware).not.toContain("ndsep_admin_2026"); + }); + + it("keeps Node production authentication tied to real Keycloak configuration", () => { + const validation = read("server/envValidation.ts"); + expect(validation).toContain("production requires real Keycloak IAM"); + expect(validation).toContain("production IAM requires a non-local https:// endpoint"); + expect(validation).toContain('const keycloakEnabled = process.env.KEYCLOAK_ENABLED'); + }); +}); diff --git a/server/security/lakehouseDurablePersistence.config.test.ts b/server/security/lakehouseDurablePersistence.config.test.ts new file mode 100644 index 00000000..21d66105 --- /dev/null +++ b/server/security/lakehouseDurablePersistence.config.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = path.resolve(import.meta.dirname, "../.."); +const read = (relativePath: string) => fs.readFileSync(path.join(repositoryRoot, relativePath), "utf8"); + +describe("Lakehouse durable PostgreSQL persistence contract", () => { + it("registers the Lakehouse ledger and feature-store schema in the active Drizzle journal", () => { + const journal = JSON.parse(read("drizzle/meta/_journal.json")) as { entries: Array<{ tag: string }> }; + expect(journal.entries.some((entry) => entry.tag === "0039_lakehouse_durable_postgres_storage")).toBe(true); + + const migration = read("drizzle/0039_lakehouse_durable_postgres_storage.sql"); + for (const table of ["lakehouse_ingest_records", "ml_feature_store", "ml_prediction_log", "ml_lineage"]) { + expect(migration).toContain(`CREATE TABLE IF NOT EXISTS ${table}`); + } + expect(migration).toContain("record_hash CHAR(64) NOT NULL UNIQUE"); + }); + + it("declares the same durable tables in the Drizzle ORM schema", () => { + const schema = read("drizzle/schema.ts"); + for (const declaration of [ + 'pgTable("lakehouse_ingest_records"', + 'pgTable("ml_feature_store"', + 'pgTable("ml_prediction_log"', + 'pgTable("ml_lineage"', + ]) { + expect(schema).toContain(declaration); + } + }); + + it("uses PostgreSQL as the Lakehouse ingest source of truth with durable leasing and retry", () => { + const worker = read("workers/rust/lakehouse_ingest/src/main.rs"); + expect(worker).toContain("tokio_postgres::connect"); + expect(worker).toContain("lakehouse_ingest_records"); + expect(worker).toContain("ON CONFLICT (record_hash) DO NOTHING"); + expect(worker).toContain("FOR UPDATE SKIP LOCKED"); + expect(worker).toContain("recover_expired_leases"); + expect(worker).toContain("delivery_status = 'retry'"); + expect(worker).toContain("Lakehouse ingestion has no in-memory fallback"); + expect(worker).not.toContain("VecDeque"); + expect(worker).not.toContain("Mutex { + const writer = read("workers/rust/lakehouse_writer/src/main.rs"); + expect(writer).toContain("PostgreSQL migration-owned tables are reachable"); + expect(writer).toContain("required_postgres_url"); + expect(writer).toContain("client.transaction()"); + expect(writer).toContain("enqueue_lakehouse_delivery"); + expect(writer).toContain("durable Lakehouse enqueue failed"); + expect(writer).toContain("$1::text::uuid"); + expect(writer).toContain("$4::text::jsonb"); + expect(writer).toContain("POSTGRES_TLS_CA_FILE is required in production"); + expect(writer).not.toContain("CREATE TABLE IF NOT EXISTS ml_feature_store"); + expect(writer).not.toContain("ndsep_secure_2026@localhost"); + expect(writer).not.toContain("forward_to_parquet"); + expect(writer).not.toContain("LAKEHOUSE_ANALYTICS_URL"); + }); + + it("requires certificate-validated PostgreSQL TLS for Lakehouse workers in production mode", () => { + const ingest = read("workers/rust/lakehouse_ingest/src/main.rs"); + const writer = read("workers/rust/lakehouse_writer/src/main.rs"); + for (const worker of [ingest, writer]) { + expect(worker).toContain("postgres_native_tls::MakeTlsConnector"); + expect(worker).toContain("POSTGRES_TLS_CA_FILE is required in production"); + expect(worker).toContain("Protocol::Tlsv12"); + } + }); + + it("runs against a disposable PostgreSQL database in Rust CI and requires explicit orchestration inputs", () => { + const workflow = read(".github/workflows/ci.yml"); + expect(workflow).toContain("LAKEHOUSE_INGEST_TEST_DATABASE_URL"); + expect(workflow).toContain("LAKEHOUSE_WRITER_TEST_DATABASE_URL"); + expect(workflow).toContain("ndsep_rust_ci_local_only"); + expect(workflow).toContain("Rust CI (Check + Test)"); + const compose = read("orchestration/docker-compose.yml"); + expect(compose).toContain("lakehouse-ingest:"); + expect(compose).toContain("LAKEHOUSE_INGEST_DATABASE_URL is required"); + expect(compose).toContain("LAKEHOUSE_INGEST_URL is required"); + + const productionCompose = read("docker-compose.production.yml"); + expect(productionCompose).toContain("LAKEHOUSE_INGEST_PORT: \"8304\""); + expect(productionCompose).toContain("LAKEHOUSE_WRITER_PORT: \"8305\""); + expect(productionCompose).toContain("POSTGRES_TLS_CA_FILE: /etc/ndsep/postgres-ca.crt"); + expect(productionCompose).toContain("LAKEHOUSE_INGEST_URL is required"); + + const securityGate = read(".github/workflows/security-gate.yml"); + expect(securityGate).toContain("breakGlassSiemPagerIntegration.test.ts"); + expect(securityGate).toContain("0039_lakehouse_durable_postgres_storage.sql"); + }); +}); diff --git a/server/security/mobilePostgresPersistence.config.test.ts b/server/security/mobilePostgresPersistence.config.test.ts new file mode 100644 index 00000000..6856e2da --- /dev/null +++ b/server/security/mobilePostgresPersistence.config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("mobile PostgreSQL persistence source contract", () => { + it("uses PostgreSQL Drizzle schema, adapter, and migration configuration", () => { + const schema = read("mobile/drizzle/schema.ts"); + const adapter = read("mobile/server/db.ts"); + const config = read("mobile/drizzle.config.ts"); + const baseline = read("mobile/drizzle/0000_postgresql_baseline.sql"); + const journal = read("mobile/drizzle/meta/_journal.json"); + + expect(schema).toContain('from "drizzle-orm/pg-core"'); + expect(schema).not.toContain("drizzle-orm/mysql-core"); + expect(adapter).toContain('from "drizzle-orm/node-postgres"'); + expect(adapter).toContain("mobile persistence requires a PostgreSQL DATABASE_URL"); + expect(adapter).not.toContain("onDuplicateKeyUpdate"); + expect(config).toContain('dialect: "postgresql"'); + expect(baseline).toContain('CREATE TABLE "users"'); + expect(baseline).toContain("jsonb"); + expect(baseline).not.toMatch(/AUTO_INCREMENT|ON UPDATE CURRENT_TIMESTAMP|`/); + expect(journal).toContain('"dialect": "postgresql"'); + }); + + it("keeps portable runtime and CI aligned with PostgreSQL-only mobile persistence", () => { + const compose = read("mobile/docker-compose.portable.yml"); + const packageJson = read("mobile/package.json"); + const ci = read(".github/workflows/ci.yml"); + + expect(compose).toContain("postgresql:"); + expect(compose).toContain("postgres:16.4-alpine"); + expect(compose).toContain("MOBILE_POSTGRES_PASSWORD is required"); + expect(compose).not.toMatch(/^ {2}mysql:/m); + expect(packageJson).not.toContain('"mysql2"'); + expect(ci).toContain("MOBILE_POSTGRES_TEST_DATABASE_URL"); + expect(ci).toContain("pnpm exec drizzle-kit migrate"); + expect(ci).toContain("DATABASE_URL: ${{ env.MOBILE_POSTGRES_TEST_DATABASE_URL }}"); + }); +}); diff --git a/server/security/mojaloopReceiptIntegrity.config.test.ts b/server/security/mojaloopReceiptIntegrity.config.test.ts new file mode 100644 index 00000000..7edbe310 --- /dev/null +++ b/server/security/mojaloopReceiptIntegrity.config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("Mojaloop receipt-integrity source contract", () => { + it("does not fabricate a committed transfer state from a successful submission", () => { + const adapter = read("server/mojaloop.ts"); + const transfer = adapter.slice(adapter.indexOf("export async function executeTransfer"), adapter.indexOf("// ─── Smoke Test")); + expect(transfer).toContain('reportedState || "ACCEPTED"'); + expect(transfer).toContain("Terminal settlement"); + expect(transfer).not.toContain('d.transferState ?? "COMMITTED"'); + }); + + it("requires a durable Mojaloop settlement identifier before reporting success", () => { + const adapter = read("server/mojaloop.ts"); + const settlement = adapter.slice(adapter.indexOf("export async function createSettlement"), adapter.indexOf("export async function getSettlements")); + expect(settlement).toContain("const settlementId"); + expect(settlement).toContain("ok: ok && settlementId.length > 0"); + expect(settlement).toContain("cannot be reconciled safely"); + }); + + it("keeps authenticated callback reconciliation as the payment-command terminal path", () => { + const callback = read("server/mojaloopCallback.ts"); + expect(callback).toContain("applyMojaloopPaymentCallback"); + expect(callback).toContain("verifyCallback"); + expect(callback).toContain("Illegal transfer state transition"); + }); +}); diff --git a/server/security/paymentCommandLifecycle.config.test.ts b/server/security/paymentCommandLifecycle.config.test.ts new file mode 100644 index 00000000..30e6afff --- /dev/null +++ b/server/security/paymentCommandLifecycle.config.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../.."); +const read = (relative: string) => readFileSync(path.join(root, relative), "utf8"); + +describe("payment command durable lifecycle source contract", () => { + const migration = read("drizzle/0040_payment_command_durable_lifecycle.sql"); + const journal = read("drizzle/meta/_journal.json"); + const schema = read("drizzle/schema.ts"); + const processor = read("server/paymentCommandProcessor.ts"); + const banking = read("server/routers/banking.ts"); + const callback = read("server/mojaloopCallback.ts"); + const lifecycle = read("server/_core/index.ts"); + const ci = read(".github/workflows/ci.yml"); + + it("registers a DDL-only migration that binds each command to exactly one payment row", () => { + expect(journal).toContain('"tag": "0040_payment_command_durable_lifecycle"'); + expect(migration).toContain("CREATE TABLE payment_commands"); + expect(migration).toContain("payment_commands_target_shape CHECK"); + expect(migration).toMatch(/payment_commands_nip_transaction_unique/); + expect(migration).toMatch(/payment_commands_rtgs_transaction_unique/); + expect(migration).not.toMatch(/^\s*(INSERT|UPDATE|DELETE|COPY)\b/im); + }); + + it("keeps the active Drizzle declarations aligned with the command and confirmation lifecycle", () => { + expect(schema).toContain('pgEnum("payment_command_status"'); + expect(schema).toContain('"pending_confirmation"'); + expect(schema).toContain('pgTable("payment_commands"'); + expect(schema).toContain('tigerbeetleTransactionId'); + expect(schema).toContain('mojaloopReference'); + }); + + it("persists NIP and RTGS instructions with commands in one PostgreSQL transaction", () => { + expect(banking).toContain("async function persistPaymentAndCommand("); + expect(banking).toContain('await client.query("BEGIN")'); + expect(banking).toContain("await enqueuePaymentCommand(client"); + expect(banking).toContain('paymentKind: "nip"'); + expect(banking).toContain('paymentKind: "rtgs"'); + expect(banking).not.toContain("[Mojaloop] NIP settlement fire-and-forget"); + expect(banking).not.toContain("[TigerBeetle] RTGS ledger fire-and-forget"); + }); + + it("claims work through PostgreSQL leases and uses only signed callbacks for terminal completion", () => { + expect(processor).toContain("FOR UPDATE SKIP LOCKED"); + expect(processor).toContain("lease_expires_at"); + expect(processor).toContain("applyMojaloopPaymentCallback"); + expect(callback).toContain("verifyCallback(req)"); + expect(callback).toContain("applyMojaloopPaymentCallback(transferId, state, completedAt)"); + expect(callback).not.toContain("FROM financial_ledger WHERE reference"); + }); + + it("starts only after database initialization and runs its real PostgreSQL test in Node CI", () => { + expect(lifecycle).toContain("startPaymentCommandProcessor();"); + expect(lifecycle).toContain("stopPaymentCommandProcessor();"); + expect(ci).toContain("PAYMENT_COMMAND_TEST_DATABASE_URL:"); + }); +}); diff --git a/server/security/permifyPostgresFailClosed.config.test.ts b/server/security/permifyPostgresFailClosed.config.test.ts new file mode 100644 index 00000000..57e52ee1 --- /dev/null +++ b/server/security/permifyPostgresFailClosed.config.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("Permify PostgreSQL and fail-closed source contract", () => { + it("uses PostgreSQL rather than the in-memory authorization datastore in active Compose profiles", () => { + for (const compose of ["docker-compose.production.yml", "docker-compose.middleware.yml", "orchestration/docker-compose.yml"]) { + const source = read(compose); + const start = source.indexOf(" permify:"); + expect(start).toBeGreaterThanOrEqual(0); + const remainder = source.slice(start); + const nextService = remainder.search(/\n {2}[A-Za-z0-9_-]+:/); + const permify = nextService === -1 ? remainder : remainder.slice(0, nextService); + expect(permify).toContain("PERMIFY_DATABASE_ENGINE: postgres"); + expect(permify).toContain("PERMIFY_DATABASE_URI"); + expect(permify).not.toContain("PERMIFY_DATABASE_ENGINE: memory"); + } + }); + + it("does not substitute a localhost endpoint or local role map for authorization", () => { + const client = read("orchestration/go/pkg/permify/client.go"); + const iam = read("orchestration/go/cmd/iam_service/main.go"); + const manager = read("server/workerManager.ts"); + expect(client).toContain("PERMIFY_URL is required for authorization decisions"); + expect(client).not.toContain('url = "http://localhost:3476"'); + expect(iam).toContain("PERMIFY_URL and PERMIFY_TENANT are required when Permify is enabled"); + expect(iam).toContain("permify authorization service is unavailable"); + expect(manager).toContain("PERMIFY_TENANT: process.env.PERMIFY_TENANT ?? \"\""); + expect(manager).not.toContain("local role-map fallback"); + }); + + it("marks the IAM health endpoint unavailable unless both Keycloak and Permify are available", () => { + const iam = read("orchestration/go/cmd/iam_service/main.go"); + expect(iam).toContain("if !kOK || !pOK"); + expect(iam).toContain('status = "unavailable"'); + expect(iam).toContain("Keycloak and Permify must remain enabled in production"); + }); +}); diff --git a/server/security/postMergeGovernanceVerifier.test.ts b/server/security/postMergeGovernanceVerifier.test.ts new file mode 100644 index 00000000..3a56aea0 --- /dev/null +++ b/server/security/postMergeGovernanceVerifier.test.ts @@ -0,0 +1,112 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { verifyPostMergeGovernance } from "../../scripts/ci/verify-post-merge-governance.mjs"; + +const temporaryDirectories: string[] = []; + +async function createFixtureDirectory() { + const directory = await mkdtemp(join(tmpdir(), "ndsep-post-merge-governance-")); + temporaryDirectories.push(directory); + return directory; +} + +async function writeFixture(directory: string, filename: string, value: unknown) { + await writeFile(join(directory, filename), `${JSON.stringify(value, null, 2)}\n`); +} + +async function writePassingFixtures(directory: string) { + await writeFixture(directory, "codeowners.json", { path: ".github/CODEOWNERS", content: "LyAqIEBtdW5pc3A=" }); + await writeFixture(directory, "codeowners-errors.json", { errors: [] }); + await writeFixture(directory, "production-protection.json", { + required_pull_request_reviews: { + required_approving_review_count: 2, + require_code_owner_reviews: true, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_status_checks: { strict: true }, + enforce_admins: { enabled: true }, + allow_force_pushes: { enabled: false }, + allow_deletions: { enabled: false }, + }); + await writeFixture(directory, "production-release-environment.json", { + name: "production-release", + protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User", id: 123 }] }], + prevent_self_review: true, + can_admins_bypass: false, + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, + }); + await writeFixture(directory, "pr19.json", { + number: 19, + merged_at: "2026-08-31T16:00:00Z", + base: { ref: "production" }, + }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("post-merge governance verifier", () => { + it("passes only when the bootstrap is merged and every required control is enforced", async () => { + const directory = await createFixtureDirectory(); + await writePassingFixtures(directory); + + expect(verifyPostMergeGovernance({ repository: "munisp/ndsep", branch: "production", pullRequest: 19, fixtureDirectory: directory })).toEqual({ + status: "passed", + repository: "munisp/ndsep", + branch: "production", + pullRequest: 19, + errors: [], + }); + }); + + it("fails closed when the live policy does not require CODEOWNER review", async () => { + const directory = await createFixtureDirectory(); + await writePassingFixtures(directory); + await writeFixture(directory, "production-protection.json", { + required_pull_request_reviews: { + required_approving_review_count: 2, + require_code_owner_reviews: false, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_status_checks: { strict: true }, + enforce_admins: { enabled: true }, + allow_force_pushes: { enabled: false }, + allow_deletions: { enabled: false }, + }); + + const result = verifyPostMergeGovernance({ repository: "munisp/ndsep", branch: "production", pullRequest: 19, fixtureDirectory: directory }); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("production: required CODEOWNER review is disabled"); + }); + + it("fails closed when the protected release environment permits administrator bypass", async () => { + const directory = await createFixtureDirectory(); + await writePassingFixtures(directory); + await writeFixture(directory, "production-release-environment.json", { + name: "production-release", + protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User", id: 123 }] }], + prevent_self_review: true, + can_admins_bypass: true, + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, + }); + + const result = verifyPostMergeGovernance({ repository: "munisp/ndsep", branch: "production", pullRequest: 19, fixtureDirectory: directory }); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("production-release: administrator bypass is enabled"); + }); + + it("fails closed when the bootstrap pull request is not merged", async () => { + const directory = await createFixtureDirectory(); + await writePassingFixtures(directory); + await writeFixture(directory, "pr19.json", { number: 19, merged_at: null, base: { ref: "production" } }); + + const result = verifyPostMergeGovernance({ repository: "munisp/ndsep", branch: "production", pullRequest: 19, fixtureDirectory: directory }); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("PR #19: bootstrap pull request is not merged"); + }); +}); diff --git a/server/security/prReadinessCollector.config.test.ts b/server/security/prReadinessCollector.config.test.ts new file mode 100644 index 00000000..15babc87 --- /dev/null +++ b/server/security/prReadinessCollector.config.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => + fs.readFileSync(path.join(root, relative), "utf8"); + +describe("PR readiness collector source contract", () => { + it("collects current review/check evidence read-only and never performs a GitHub mutation", () => { + const script = read("scripts/ci/collect-pr-readiness-evidence.mjs"); + expect(script).toContain("READ_ONLY_PR_READINESS_COLLECTION"); + expect(script).toMatch(/"pr",\s*"view"/); + expect(script).toContain("review.commit?.oid === head"); + expect(script).toContain("independentApprovalAtHead"); + expect(script).not.toContain('["pr", "review"'); + expect(script).not.toContain('["pr", "merge"'); + expect(script).not.toContain('["workflow", "run"'); + expect(script).not.toContain('["api", "--method", "POST"'); + expect(script).not.toContain('["api", "--method", "PUT"'); + expect(script).not.toContain('["api", "--method", "PATCH"'); + expect(script).not.toContain('["api", "--method", "DELETE"'); + expect(script).not.toContain('"--update-snapshots"'); + }); + + it("uses exact Playwright discovery rather than a hard-coded visual baseline list", () => { + const script = read("scripts/ci/collect-pr-readiness-evidence.mjs"); + expect(script).toContain('"e2e/visual-regression.spec.ts"'); + expect(script).toMatch( + /"--project=chromium",\s*"--list",\s*"--reporter=line"/ + ); + expect(script).toContain('PLAYWRIGHT_HTML_OPEN: "never"'); + expect(script).toContain("Visual baseline review set"); + expect(script).not.toContain('"login-page", "dashboard-home"'); + }); + + it("records missing immediate and production evidence as blocked rather than creating approvals", () => { + const script = read("scripts/ci/collect-pr-readiness-evidence.mjs"); + expect(script).toContain("GOV-001"); + expect(script).toContain("E2E-001"); + expect(script).toContain("SEC-001"); + expect(script).toContain("complianceBaselineRegister"); + expect(script).toContain("not evidenced by this read-only PR collector"); + expect(script).toContain( + 'process.exitCode = report.decision.status === "BLOCKED" ? 1 : 0' + ); + }); + + it("is wired into the protected source-control security gate", () => { + const workflow = read(".github/workflows/security-gate.yml"); + expect(workflow).toContain( + "server/security/prReadinessCollector.config.test.ts" + ); + }); +}); diff --git a/server/security/productionGoEvidenceVerifier.test.ts b/server/security/productionGoEvidenceVerifier.test.ts new file mode 100644 index 00000000..d6d41ff4 --- /dev/null +++ b/server/security/productionGoEvidenceVerifier.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { verifyProductionGoEvidence } from "../../scripts/ci/verify-production-go-evidence.mjs"; + +const DIGEST = "a".repeat(64); +const SOURCE = "b".repeat(40); +const IMAGE = `ghcr.io/munisp/ndsep@sha256:${DIGEST}`; +const URI = "https://github.com/munisp/ndsep/actions/runs/123/artifacts/evidence"; +const directories: string[] = []; + +async function directory() { + const value = await mkdtemp(join(tmpdir(), "ndsep-production-go-")); + directories.push(value); + return value; +} + +async function put(dir: string, name: string, value: unknown) { + await writeFile(join(dir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +const passedChecks = (names: string[]) => names.map(name => ({ name, status: "passed" })); + +async function writeCompleteEvidence(dir: string) { + await put(dir, "candidate.json", { + image: IMAGE, + sourceCommit: SOURCE, + builtAt: "2026-09-01T20:00:00.000Z", + releaseWorkflow: { path: ".github/workflows/ci.yml", runId: 123, evidenceUri: URI }, + }); + await put(dir, "trivy.json", { scanner: "trivy", mode: "direct-image", scanTarget: IMAGE, results: [] }); + await put(dir, "artifact-evidence.json", { + sbom: { imageDigest: DIGEST, verified: true, uri: `${URI}/sbom`, sha256: "c".repeat(64), format: "CycloneDX" }, + provenance: { + imageDigest: DIGEST, verified: true, uri: `${URI}/provenance`, sha256: "d".repeat(64), format: "SLSA", + verification: { subjectDigestBound: true }, + }, + cosign: { + imageDigest: DIGEST, verified: true, uri: `${URI}/cosign`, sha256: "e".repeat(64), + certificateIdentity: "https://github.com/munisp/ndsep/.github/workflows/ci.yml@refs/heads/production", + verification: { issuer: "https://token.actions.githubusercontent.com" }, + }, + }); + await put(dir, "governance.json", { + branch: "production", requiredApprovals: 2, requireCodeOwnerReviews: true, + dismissStaleReviews: true, requireLastPushApproval: true, strictStatusChecks: true, + enforceAdmins: true, allowForcePushes: false, allowDeletions: false, codeownersErrors: [], + productionReleaseEnvironment: { protected: true, preventSelfReview: true, requiredReviewers: true, allowAdminBypass: false }, + mergedPullRequest: { + number: 18, merged: true, mergeCommit: SOURCE, author: "pr-author", mergedAt: "2026-09-01T19:00:00.000Z", evidenceUri: `${URI}/merge`, + approvals: [ + { state: "APPROVED", actor: "security-reviewer", submittedAt: "2026-09-01T18:00:00.000Z", evidenceUri: `${URI}/review-security` }, + { state: "APPROVED", actor: "platform-reviewer", submittedAt: "2026-09-01T18:05:00.000Z", evidenceUri: `${URI}/review-platform` }, + ], + }, + }); + await put(dir, "staging-deployment.json", { + environment: "staging", protectedEnvironment: true, deploymentDigest: DIGEST, deploymentCommit: SOURCE, + status: "passed", mtlsContract: "passed", authorizationNegativePath: "passed", workflowRunId: 456, evidenceUri: `${URI}/staging`, + checks: passedChecks(["candidate-health", "image-lock", "rollback", "database-recovery", "network-policy", "pwa-smoke", "mobile-smoke"]), + }); + const services = ["postgresql", "tigerbeetle", "redis", "mojaloop", "kafka", "apisix", "keycloak", "openappsec", "permify", "opensearch", "fluvio", "dapr", "temporal", "lakehouse"] + .map(name => ({ name, status: "passed", digest: DIGEST, evidenceUri: `${URI}/services/${name}`, workflowRunId: 456 })); + await put(dir, "service-matrix.json", { services }); + await put(dir, "postgres-integrity.json", { + status: "passed", candidateDigest: DIGEST, workflowRunId: 457, evidenceUri: `${URI}/postgres`, + checks: passedChecks(["encrypted-backup-restore", "forced-rls-denial", "ledger-recompute", "advisory-lock-concurrency"]), + }); + await put(dir, "resilience.json", { + status: "passed", candidateDigest: DIGEST, workflowRunId: 458, evidenceUri: `${URI}/resilience`, + checks: passedChecks(["stateful-service-fault-recovery", "alert-escalation", "reconciliation"]), + }); + await put(dir, "residency-evidence.json", { + status: "passed", candidateDigest: DIGEST, workflowRunId: 459, evidenceUri: `${URI}/residency`, + checks: passedChecks(["signed-evidence-verification", "replay-rejection", "merkle-transparency", "appeal-workflow"]), + }); + const roles = ["release-security", "security", "sre", "data-owner", "compliance-evidence-verifier", "engineering-owner", "compliance-officer", "business-owner", "release-manager"]; + await put(dir, "approvals.json", { + candidateDigest: DIGEST, + acceptances: roles.map((role, index) => ({ + role, decision: "accepted", actor: `${role}-approver`, acceptedAt: `2026-09-01T20:0${index}:00.000Z`, evidenceUri: `${URI}/approvals/${role}`, + })), + }); +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map(item => rm(item, { recursive: true, force: true }))); +}); + +describe("95-point production GO evidence verifier", () => { + it("accepts complete digest-bound, independently approved production evidence", async () => { + const dir = await directory(); + await writeCompleteEvidence(dir); + expect(verifyProductionGoEvidence(dir)).toEqual({ status: "passed", threshold: "95/100", evidenceDirectory: dir, errors: [] }); + }); + + it("blocks self-approval even when basic readiness evidence is complete", async () => { + const dir = await directory(); + await writeCompleteEvidence(dir); + const governance = JSON.parse(await (await import("node:fs/promises")).readFile(join(dir, "governance.json"), "utf8")); + governance.mergedPullRequest.approvals[0].actor = "pr-author"; + await put(dir, "governance.json", governance); + const result = verifyProductionGoEvidence(dir); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("governance.json: two independent, timestamped approval records are required"); + }); + + it("blocks an otherwise complete pack without a passed protected-staging recovery check", async () => { + const dir = await directory(); + await writeCompleteEvidence(dir); + const staging = JSON.parse(await (await import("node:fs/promises")).readFile(join(dir, "staging-deployment.json"), "utf8")); + staging.checks = staging.checks.filter((item: { name: string }) => item.name !== "database-recovery"); + await put(dir, "staging-deployment.json", staging); + const result = verifyProductionGoEvidence(dir); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("staging-deployment.json: mandatory 'database-recovery' check is absent or failed"); + }); +}); diff --git a/server/security/productionGoEvidenceWorkflow.config.test.ts b/server/security/productionGoEvidenceWorkflow.config.test.ts new file mode 100644 index 00000000..2ed6fff7 --- /dev/null +++ b/server/security/productionGoEvidenceWorkflow.config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("production GO evidence workflow source contract", () => { + it("is manually invoked only from production through the protected release environment", () => { + const workflow = read(".github/workflows/production-go-evidence-verification.yml"); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("github.ref == 'refs/heads/production'"); + expect(workflow).toContain("VERIFY_PRODUCTION_GO_EVIDENCE"); + expect(workflow).toContain("name: production-release"); + expect(workflow).toContain("actions: read"); + expect(workflow).not.toContain("packages: write"); + expect(workflow).not.toContain("docker/build-push-action"); + }); + + it("requires evidence from a successful staging acceptance run for the same production commit", () => { + const workflow = read(".github/workflows/production-go-evidence-verification.yml"); + expect(workflow).toContain("Staging Production GO Acceptance"); + expect(workflow).toContain(".headSha == $sha"); + expect(workflow).toContain('.headBranch == "production"'); + expect(workflow).toContain('.conclusion == "success"'); + expect(workflow).toContain('"staging-go-evidence-$SOURCE_COMMIT"'); + }); + + it("uses the anti-tampering collector and blocks unless the 95-point verifier passes", () => { + const workflow = read(".github/workflows/production-go-evidence-verification.yml"); + expect(workflow).toContain("collect-staging-release-evidence.mjs"); + expect(workflow).toContain("--confirm COLLECT_REAL_EVIDENCE"); + expect(workflow).toContain("verify-production-go-evidence.mjs"); + expect(workflow).toContain("production-go-verification.json"); + }); +}); diff --git a/server/security/productionGovernanceAutomation.test.ts b/server/security/productionGovernanceAutomation.test.ts new file mode 100644 index 00000000..485789c4 --- /dev/null +++ b/server/security/productionGovernanceAutomation.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + assessRootGaps, + buildEnvironmentPayload, + buildPlan, + buildReviewProtectionPayload, +} from "../../scripts/ci/apply-production-governance.mjs"; + +function passingState() { + return { + pullRequest: { merged_at: "2026-08-31T17:00:00Z" }, + codeowners: { path: ".github/CODEOWNERS", content: "LyAqIEBtdW5pc3A=" }, + codeownersErrors: { errors: [] }, + protection: { + required_pull_request_reviews: { + required_approving_review_count: 2, + require_code_owner_reviews: true, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_status_checks: { strict: true }, + enforce_admins: { enabled: true }, + allow_force_pushes: { enabled: false }, + allow_deletions: { enabled: false }, + }, + environment: { + name: "production-release", + protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User", id: 42 }] }], + prevent_self_review: true, + can_admins_bypass: false, + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, + }, + }; +} + +describe("production governance automation", () => { + it("builds a narrow review-protection payload without replacing status-check configuration", () => { + expect(buildReviewProtectionPayload()).toEqual({ + dismiss_stale_reviews: true, + require_code_owner_reviews: true, + required_approving_review_count: 2, + require_last_push_approval: true, + }); + }); + + it("builds a protected production-release environment payload with self-review prevention", () => { + expect(buildEnvironmentPayload(42)).toEqual({ + wait_timer: 0, + reviewers: [{ type: "User", id: 42 }], + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, + prevent_self_review: true, + }); + expect(() => buildEnvironmentPayload(0)).toThrow("Reviewer ID must be a positive integer"); + }); + + it("reports every root governance gap in plan mode", () => { + const state = passingState(); + state.pullRequest.merged_at = null; + state.codeowners = null; + state.codeownersErrors = null; + state.protection.required_pull_request_reviews.required_approving_review_count = 1; + state.protection.required_pull_request_reviews.require_code_owner_reviews = false; + state.environment = null; + + const gaps = assessRootGaps(state); + expect(gaps).toEqual( + expect.arrayContaining([ + "pr-not-merged", + "codeowners-missing", + "codeowners-invalid", + "two-approvals-disabled", + "codeowner-review-disabled", + "production-release-missing", + "production-release-reviewers-missing", + "production-release-self-review-denial-disabled", + "production-release-admin-bypass-enabled", + "production-release-branch-policy-disabled", + ]) + ); + expect(buildPlan({ repository: "munisp/ndsep", branch: "production", environment: "production-release", pullRequest: 19, state }).mode).toBe("plan"); + }); + + it("reports no gaps only when every governance control is present", () => { + expect(assessRootGaps(passingState())).toEqual([]); + }); +}); diff --git a/server/security/productionHardening.config.test.ts b/server/security/productionHardening.config.test.ts index efd520c7..b4611071 100644 --- a/server/security/productionHardening.config.test.ts +++ b/server/security/productionHardening.config.test.ts @@ -3,10 +3,16 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const root = resolve(import.meta.dirname, "../.."); -const read = (relativePath: string) => readFileSync(resolve(root, relativePath), "utf8"); +const read = (relativePath: string) => + readFileSync(resolve(root, relativePath), "utf8"); function composeServiceBlock(compose: string, service: string): string { - const match = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [A-Za-z0-9_-]+:|^# ─── Volumes|\\Z)`, "m")); + const match = compose.match( + new RegExp( + `^ ${service}:\\n([\\s\\S]*?)(?=^ [A-Za-z0-9_-]+:|^# ─── Volumes|\\Z)`, + "m" + ) + ); if (!match) throw new Error(`Missing Compose service '${service}'`); return match[0]; } @@ -14,7 +20,9 @@ function composeServiceBlock(compose: string, service: string): string { describe("production security configuration", () => { it("keeps Caddy as the only host-published production service", () => { const compose = read("docker-compose.production.yml"); - const allPortBlocks = [...compose.matchAll(/^ {4}ports:\n((?: {6}- .*\n)+)/gm)]; + const allPortBlocks = [ + ...compose.matchAll(/^ {4}ports:\n((?: {6}- .*\n)+)/gm), + ]; expect(allPortBlocks).toHaveLength(1); const caddy = composeServiceBlock(compose, "caddy"); expect(caddy).toContain('"80:80"'); @@ -26,13 +34,19 @@ describe("production security configuration", () => { it("requires a Caddy edge, OpenAppSec APISIX attachment, and internal OPA service", () => { const compose = read("docker-compose.production.yml"); - expect(composeServiceBlock(compose, "caddy")).toContain("no-new-privileges:true"); + expect(composeServiceBlock(compose, "caddy")).toContain( + "no-new-privileges:true" + ); const apisix = composeServiceBlock(compose, "apisix"); expect(apisix).toContain("ghcr.io/openappsec/apisix-attachment"); expect(apisix).toContain("ipc: service:openappsec-agent"); expect(apisix).toContain("APISIX_STAND_ALONE"); - expect(composeServiceBlock(compose, "opa")).toContain("openpolicyagent/opa"); - expect(composeServiceBlock(compose, "ndsep-api")).toContain('OPA_ENABLED: "true"'); + expect(composeServiceBlock(compose, "opa")).toContain( + "openpolicyagent/opa" + ); + expect(composeServiceBlock(compose, "ndsep-api")).toContain( + 'OPA_ENABLED: "true"' + ); }); it("requires OIDC and trusted-forwarded-client limits at APISIX", () => { @@ -49,7 +63,9 @@ describe("production security configuration", () => { }); it("retains Keycloak MFA enrollment, PKCE, short sessions, and non-wildcard redirects", () => { - const realm = JSON.parse(read("orchestration/keycloak/ndsep-realm.json")) as { + const realm = JSON.parse( + read("orchestration/keycloak/ndsep-realm.json") + ) as { accessTokenLifespan: number; ssoSessionIdleTimeout: number; requiredActions: Array<{ alias: string; defaultAction: boolean }>; @@ -57,25 +73,61 @@ describe("production security configuration", () => { redirectUris: string[]; directAccessGrantsEnabled: boolean; attributes: Record; - protocolMappers: Array<{ name: string; protocolMapper: string; config: Record }>; + protocolMappers: Array<{ + name: string; + protocolMapper: string; + config: Record; + }>; }>; }; expect(realm.accessTokenLifespan).toBeLessThanOrEqual(600); expect(realm.ssoSessionIdleTimeout).toBeLessThanOrEqual(900); - expect(realm.requiredActions).toContainEqual(expect.objectContaining({ alias: "CONFIGURE_TOTP", defaultAction: true })); + expect(realm.requiredActions).toContainEqual( + expect.objectContaining({ alias: "CONFIGURE_TOTP", defaultAction: true }) + ); expect(realm.clients[0].directAccessGrantsEnabled).toBe(false); - expect(realm.clients[0].attributes["pkce.code.challenge.method"]).toBe("S256"); + expect(realm.clients[0].attributes["pkce.code.challenge.method"]).toBe( + "S256" + ); expect(realm.clients[0].protocolMappers).toContainEqual( expect.objectContaining({ name: "ndsep-access-token-amr", protocolMapper: "oidc-amr-mapper", config: expect.objectContaining({ "access.token.claim": "true" }), - }), + }) ); expect(realm.clients[0].redirectUris.join("\n")).not.toContain("localhost"); expect(realm.clients[0].redirectUris.join("\n")).not.toContain("*.manus"); }); + it("keeps the production template aligned with explicit IAM and OpenSearch startup gates", () => { + const environment = read(".env.production.example"); + expect(environment).toContain("KEYCLOAK_ENABLED=true"); + expect(environment).toContain("KEYCLOAK_URL=https://"); + expect(environment).toContain("KEYCLOAK_ISSUER_URL=https://"); + expect(environment).toContain("OPENSEARCH_ENABLED=true"); + expect(environment).toContain("OPENSEARCH_URL=https://"); + }); + + it("requires explicit production values for privileged credentials and alert routes", () => { + const compose = read("docker-compose.production.yml"); + for (const requiredSetting of [ + "SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:?SLACK_WEBHOOK_URL is required}", + "PAGERDUTY_INTEGRATION_KEY: ${PAGERDUTY_INTEGRATION_KEY:?PAGERDUTY_INTEGRATION_KEY is required}", + "APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:?APISIX_ADMIN_KEY is required}", + "NETBOX_TOKEN: ${NETBOX_TOKEN:?NETBOX_TOKEN is required}", + "RANGER_ADMIN_USER: ${RANGER_ADMIN_USER:?RANGER_ADMIN_USER is required}", + "RANGER_ADMIN_PASS: ${RANGER_ADMIN_PASS:?RANGER_ADMIN_PASS is required}", + "JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}", + "SIGNING_KEY: ${EVIDENCE_SIGNING_KEY:?EVIDENCE_SIGNING_KEY is required}", + ]) { + expect(compose).toContain(requiredSetting); + } + expect(compose).not.toMatch( + /PLACEHOLDER|rangerR0cks!|ndsep-evidence-signing-key-change-in-prod|edd1c9f034335f136f87ad84b625c8f1/ + ); + }); + it("requires OPA MFA assurance for privileged production decisions", () => { const policy = read("infra/opa/policies/ndsep_authz.rego"); expect(policy).toContain("default allow := false"); diff --git a/server/security/readiness60EvidenceVerifier.test.ts b/server/security/readiness60EvidenceVerifier.test.ts new file mode 100644 index 00000000..5e57ded8 --- /dev/null +++ b/server/security/readiness60EvidenceVerifier.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { verifyReadiness60Evidence } from "../../scripts/ci/verify-60-readiness-evidence.mjs"; + +const DIGEST = "a".repeat(64); +const IMAGE = `ghcr.io/munisp/ndsep@sha256:${DIGEST}`; +const temporaryDirectories: string[] = []; + +async function createEvidenceDirectory() { + const directory = await mkdtemp(join(tmpdir(), "ndsep-readiness-60-")); + temporaryDirectories.push(directory); + return directory; +} + +async function writeEvidence(directory: string, filename: string, value: unknown) { + await writeFile(join(directory, filename), `${JSON.stringify(value, null, 2)}\n`); +} + +async function writeValidEvidence(directory: string) { + await writeEvidence(directory, "candidate.json", { + image: IMAGE, + sourceCommit: "b".repeat(40), + builtAt: "2026-08-31T15:00:00.000Z", + }); + await writeEvidence(directory, "trivy.json", { + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: [], + }); + await writeEvidence(directory, "artifact-evidence.json", { + sbom: { imageDigest: DIGEST, verified: true, uri: "s3://evidence/sbom.cdx.json", format: "CycloneDX" }, + provenance: { imageDigest: DIGEST, verified: true, uri: "s3://evidence/provenance.json", format: "SLSA" }, + cosign: { + imageDigest: DIGEST, + verified: true, + uri: "s3://evidence/cosign.json", + certificateIdentity: "https://github.com/munisp/ndsep/.github/workflows/ci.yml@refs/heads/production", + }, + }); + await writeEvidence(directory, "governance.json", { + branch: "production", + requiredApprovals: 2, + requireCodeOwnerReviews: true, + dismissStaleReviews: true, + requireLastPushApproval: true, + strictStatusChecks: true, + enforceAdmins: true, + allowForcePushes: false, + allowDeletions: false, + codeownersErrors: [], + productionReleaseEnvironment: { protected: true, preventSelfReview: true }, + }); + await writeEvidence(directory, "staging-deployment.json", { + environment: "staging", + protectedEnvironment: true, + deploymentDigest: DIGEST, + status: "passed", + mtlsContract: "passed", + authorizationNegativePath: "passed", + checks: [ + { name: "candidate-health", status: "passed" }, + { name: "image-lock", status: "passed" }, + ], + }); + const services = [ + "postgresql", "tigerbeetle", "redis", "mojaloop", "kafka", "apisix", + "keycloak", "openappsec", "permify", "opensearch", "fluvio", + ].map((name) => ({ + name, + status: "passed", + digest: DIGEST, + evidenceUri: `s3://evidence/services/${name}.json`, + })); + await writeEvidence(directory, "service-matrix.json", { services }); + await writeEvidence(directory, "postgres-integrity.json", { + status: "passed", + checks: [ + "encrypted-backup-restore", "forced-rls-denial", "ledger-recompute", "advisory-lock-concurrency", + ].map((name) => ({ name, status: "passed" })), + }); + await writeEvidence(directory, "resilience.json", { + status: "passed", + checks: ["stateful-service-fault-recovery", "alert-escalation", "reconciliation"].map((name) => ({ name, status: "passed" })), + }); + await writeEvidence(directory, "residency-evidence.json", { + status: "passed", + checks: ["signed-evidence-verification", "replay-rejection", "merkle-transparency", "appeal-workflow"].map((name) => ({ name, status: "passed" })), + }); + await writeEvidence(directory, "approvals.json", { + candidateDigest: DIGEST, + acceptances: ["release-security", "sre", "data-owner", "compliance-evidence-verifier", "security"].map((role) => ({ role, decision: "accepted" })), + }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("60-point readiness evidence verifier", () => { + it("accepts a complete evidence pack bound to one candidate digest", async () => { + const directory = await createEvidenceDirectory(); + await writeValidEvidence(directory); + + expect(verifyReadiness60Evidence(directory)).toEqual({ + status: "passed", + threshold: "60/100", + evidenceDirectory: directory, + errors: [], + }); + }); + + it("fails closed when an SBOM format is not explicitly accepted", async () => { + const directory = await createEvidenceDirectory(); + await writeValidEvidence(directory); + await writeEvidence(directory, "artifact-evidence.json", { + sbom: { imageDigest: DIGEST, verified: true, uri: "s3://evidence/sbom.unknown.json", format: "UNKNOWN" }, + provenance: { imageDigest: DIGEST, verified: true, uri: "s3://evidence/provenance.json", format: "SLSA" }, + cosign: { imageDigest: DIGEST, verified: true, uri: "s3://evidence/cosign.json", certificateIdentity: "https://github.com/munisp/ndsep/.github/workflows/ci.yml@refs/heads/production" }, + }); + const result = verifyReadiness60Evidence(directory); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("artifact-evidence.json: an SPDX or CycloneDX SBOM is required"); + }); + + it("fails closed when governance does not require code-owner review", async () => { + const directory = await createEvidenceDirectory(); + await writeValidEvidence(directory); + await writeEvidence(directory, "governance.json", { + branch: "production", + requiredApprovals: 2, + requireCodeOwnerReviews: false, + dismissStaleReviews: true, + requireLastPushApproval: true, + strictStatusChecks: true, + enforceAdmins: true, + allowForcePushes: false, + allowDeletions: false, + codeownersErrors: [], + productionReleaseEnvironment: { protected: true, preventSelfReview: true }, + }); + + const result = verifyReadiness60Evidence(directory); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("governance.json: required CODEOWNER review is not enabled"); + }); + + it("fails closed when the direct image scan contains a critical vulnerability", async () => { + const directory = await createEvidenceDirectory(); + await writeValidEvidence(directory); + await writeEvidence(directory, "trivy.json", { + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: [{ vulnerabilities: [{ vulnerabilityId: "CVE-2026-0001", severity: "CRITICAL" }] }], + }); + + const result = verifyReadiness60Evidence(directory); + expect(result.status).toBe("blocked"); + expect(result.errors).toContain("trivy.json: found 1 HIGH/CRITICAL vulnerability finding(s)"); + }); +}); diff --git a/server/security/releaseArtifactHardening.config.test.ts b/server/security/releaseArtifactHardening.config.test.ts new file mode 100644 index 00000000..98b4e29e --- /dev/null +++ b/server/security/releaseArtifactHardening.config.test.ts @@ -0,0 +1,201 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, "../.."); +const workflowPath = resolve(root, ".github/workflows/ci.yml"); +const makefilePath = resolve(root, "Makefile"); +const lockVerifierPath = resolve( + root, + "scripts/security/verify-production-image-lock.sh" +); +const evidenceVerifierPath = resolve( + root, + "scripts/security/verify-release-image-evidence.sh" +); +const digest = "a".repeat(64); + +function verifyRenderedCompose(contents: string): string { + const directory = mkdtempSync(resolve(tmpdir(), "ndsep-image-lock-")); + const composePath = resolve(directory, "rendered-compose.yml"); + try { + writeFileSync(composePath, contents, "utf8"); + return execFileSync("bash", [lockVerifierPath, composePath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function verifyReleaseEvidence(report: object): object { + const directory = mkdtempSync(resolve(tmpdir(), "ndsep-release-evidence-")); + const trivyPath = resolve(directory, "trivy.json"); + const sbomPath = resolve(directory, "sbom.cdx.json"); + const outputPath = resolve(directory, "release-evidence.json"); + try { + writeFileSync(trivyPath, JSON.stringify(report), "utf8"); + writeFileSync( + sbomPath, + JSON.stringify({ + bomFormat: "CycloneDX", + specVersion: "1.6", + components: [], + }), + "utf8" + ); + execFileSync( + "bash", + [ + evidenceVerifierPath, + "ghcr.io/munisp/ndsep", + `sha256:${digest}`, + "b".repeat(40), + trivyPath, + sbomPath, + outputPath, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } + ); + return JSON.parse(readFileSync(outputPath, "utf8")); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("production release artifact hardening", () => { + it("accepts only lower-case immutable SHA-256 image references in rendered production compose", () => { + const result = verifyRenderedCompose( + `services:\n api:\n image: ghcr.io/munisp/ndsep@sha256:${digest}\n` + ); + expect(result).toContain("verified 1 immutable OCI image digests"); + }); + + it("rejects mutable or tag-only production image references", () => { + expect(() => + verifyRenderedCompose( + "services:\n api:\n image: ghcr.io/munisp/ndsep:latest\n" + ) + ).toThrow(/immutable lowercase OCI SHA-256 reference/); + }); + + it("rejects local build directives in rendered production Compose", () => { + expect(() => + verifyRenderedCompose("services:\n api:\n build: .\n") + ).toThrow( + /production configuration must not contain local build directives/ + ); + }); + + it("binds clean scan and SBOM evidence to the immutable digest before release signing", () => { + const evidence = verifyReleaseEvidence({ + Results: [{ Vulnerabilities: [] }], + }) as { + image: { reference: string }; + trivy: { high_critical_count: number }; + }; + expect(evidence.image.reference).toBe( + `ghcr.io/munisp/ndsep@sha256:${digest}` + ); + expect(evidence.trivy.high_critical_count).toBe(0); + }); + + it("blocks signing when direct image scan reports a HIGH or CRITICAL finding", () => { + expect(() => + verifyReleaseEvidence({ + Results: [ + { + Vulnerabilities: [ + { VulnerabilityID: "CVE-test", Severity: "HIGH" }, + ], + }, + ], + }) + ).toThrow(/release image scan found 1 HIGH\/CRITICAL/); + }); + + it("keeps the Docker release job restricted to protected production pushes", async () => { + const workflow = await import("node:fs/promises").then(({ readFile }) => + readFile(workflowPath, "utf8") + ); + expect(workflow).toMatch( + /docker:\s*\n\s+name: Docker Build & Push[\s\S]*?if: github\.ref == 'refs\/heads\/production'/ + ); + expect(workflow).toMatch( + /needs:\s*\[\s*node-ci,\s*go-ci,\s*go-orchestration-ci,\s*python-ci,\s*rust-ci,\s*security,\s*integration,\s*schema-drift-verification,?\s*\]/ + ); + expect(workflow).toMatch( + /docker:\s*\n\s+name: Docker Build & Push[\s\S]*?environment:\s*\n\s+name: production-release/ + ); + }); + + it("routes exception and release-control changes to an accountable code owner", async () => { + const codeownersPath = resolve(root, ".github/CODEOWNERS"); + const codeowners = await import("node:fs/promises").then(({ readFile }) => + readFile(codeownersPath, "utf8") + ); + for (const ownedPath of [ + "/.github/security/pnpm-audit-exceptions.json @munisp", + "/PNPM_AUDIT_EXCEPTION_POLICY.md @munisp", + "/scripts/ci/weekly-pnpm-exception-audit.mjs @munisp", + "/.github/workflows/security-gate.yml @munisp", + "/.github/workflows/ci.yml @munisp", + ]) { + expect(codeowners).toContain(ownedPath); + } + }); + + it("refuses direct registry publication and validates image locks before production Compose startup", async () => { + const makefile = await import("node:fs/promises").then(({ readFile }) => + readFile(makefilePath, "utf8") + ); + expect(makefile).toContain( + "docker-push: ## Refuse direct image publication; use the protected production workflow" + ); + expect(makefile).toContain("Direct image publishing is disabled"); + expect(makefile).toContain( + "Set PRODUCTION_ENV_FILE to an approved production environment file" + ); + expect(makefile).toContain("verify-production-image-lock.sh"); + }); + + it("requires digest-bound image scanning and retained evidence before signing", async () => { + const workflow = await import("node:fs/promises").then(({ readFile }) => + readFile(workflowPath, "utf8") + ); + const dockerJob = workflow.slice(workflow.indexOf(" docker:")); + expect(dockerJob).toContain("id: build_image"); + expect(dockerJob).toContain("provenance: mode=max"); + expect(dockerJob).toContain("sbom: true"); + expect(dockerJob).toContain("image-ref: ${{ steps.image.outputs.ref }}"); + expect(dockerJob).toContain("severity: HIGH,CRITICAL"); + expect(dockerJob).toContain('exit-code: "1"'); + expect(dockerJob).toContain("verify-release-image-evidence.sh"); + expect(dockerJob).toContain("cosign sign --yes"); + expect(dockerJob).toContain("cosign verify"); + expect(dockerJob).toContain("normalize-release-evidence.mjs"); + expect(dockerJob).toContain("--out-dir release-evidence"); + expect(dockerJob.indexOf("verify-release-image-evidence.sh")).toBeLessThan( + dockerJob.indexOf("cosign sign --yes") + ); + expect(dockerJob.indexOf("cosign verify")).toBeLessThan( + dockerJob.indexOf("normalize-release-evidence.mjs") + ); + expect(dockerJob.indexOf("normalize-release-evidence.mjs")).toBeLessThan( + dockerJob.indexOf("Upload release evidence") + ); + expect(dockerJob).toContain("release-evidence"); + expect(dockerJob).toContain( + "aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25" + ); + expect(dockerJob).toContain( + "actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a" + ); + expect(dockerJob).toContain( + "sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22" + ); + }); +}); diff --git a/server/security/releaseEvidenceNormalizer.test.ts b/server/security/releaseEvidenceNormalizer.test.ts new file mode 100644 index 00000000..e26efb65 --- /dev/null +++ b/server/security/releaseEvidenceNormalizer.test.ts @@ -0,0 +1,152 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { normalizeReleaseEvidence } from "../../scripts/ci/normalize-release-evidence.mjs"; + +const DIGEST = "a".repeat(64); +const IMAGE = `ghcr.io/munisp/ndsep@sha256:${DIGEST}`; +const SOURCE_COMMIT = "b".repeat(40); +const CERTIFICATE_IDENTITY = + "https://github.com/munisp/ndsep/.github/workflows/ci.yml@refs/heads/production"; +const temporaryDirectories: string[] = []; + +async function fixtureDirectory() { + const directory = await mkdtemp(join(tmpdir(), "ndsep-release-normalizer-")); + temporaryDirectories.push(directory); + const trivyPath = join(directory, "trivy-image.json"); + const sbomPath = join(directory, "image-sbom.cdx.json"); + const provenancePath = join(directory, "github-provenance-verify.txt"); + const cosignPath = join(directory, "cosign-verify.json"); + + await writeFile( + trivyPath, + JSON.stringify({ Results: [{ Target: IMAGE, Vulnerabilities: [] }] }) + ); + await writeFile( + sbomPath, + JSON.stringify({ + bomFormat: "CycloneDX", + specVersion: "1.6", + components: [], + }) + ); + await writeFile( + provenancePath, + "Verified attestation for immutable candidate\n" + ); + await writeFile( + cosignPath, + JSON.stringify([ + { + critical: { + image: { "docker-manifest-digest": `sha256:${DIGEST}` }, + }, + }, + ]) + ); + + return { directory, trivyPath, sbomPath, provenancePath, cosignPath }; +} + +function normalize(paths: Awaited>) { + return normalizeReleaseEvidence({ + image: IMAGE, + sourceCommit: SOURCE_COMMIT, + builtAt: "2026-08-31T20:00:00.000Z", + trivyPath: paths.trivyPath, + trivyUri: + "github://munisp/ndsep/actions/runs/1/artifacts/release-evidence/trivy-image.json", + sbomPath: paths.sbomPath, + sbomUri: + "github://munisp/ndsep/actions/runs/1/artifacts/release-evidence/image-sbom.cdx.json", + provenancePath: paths.provenancePath, + provenanceUri: + "github://munisp/ndsep/actions/runs/1/artifacts/release-evidence/github-provenance-verify.txt", + cosignPath: paths.cosignPath, + cosignUri: + "github://munisp/ndsep/actions/runs/1/artifacts/release-evidence/cosign-verify.json", + certificateIdentity: CERTIFICATE_IDENTITY, + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map(directory => rm(directory, { recursive: true, force: true })) + ); +}); + +describe("release evidence normalizer", () => { + it("preserves CycloneDX identity and binds all normalized evidence to the candidate digest", async () => { + const paths = await fixtureDirectory(); + const normalized = normalize(paths); + + expect(normalized.candidate).toEqual({ + image: IMAGE, + sourceCommit: SOURCE_COMMIT, + builtAt: "2026-08-31T20:00:00.000Z", + }); + expect(normalized.trivy).toMatchObject({ + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: [{ vulnerabilities: [] }], + }); + expect(normalized.artifactEvidence.sbom).toMatchObject({ + imageDigest: DIGEST, + verified: true, + format: "CycloneDX", + specVersion: "1.6", + }); + expect(normalized.artifactEvidence.provenance).toMatchObject({ + imageDigest: DIGEST, + verified: true, + format: "SLSA", + }); + expect(normalized.artifactEvidence.cosign).toMatchObject({ + imageDigest: DIGEST, + verified: true, + certificateIdentity: CERTIFICATE_IDENTITY, + }); + }); + + it("fails before emitting normalized evidence when the direct image scan contains a critical finding", async () => { + const paths = await fixtureDirectory(); + await writeFile( + paths.trivyPath, + JSON.stringify({ + Results: [ + { + Vulnerabilities: [ + { VulnerabilityID: "CVE-2026-test", Severity: "CRITICAL" }, + ], + }, + ], + }) + ); + + expect(() => normalize(paths)).toThrow( + "Trivy report contains 1 HIGH/CRITICAL finding(s)" + ); + }); + + it("rejects a Cosign verification whose manifest digest does not match the candidate", async () => { + const paths = await fixtureDirectory(); + await writeFile( + paths.cosignPath, + JSON.stringify([ + { + critical: { + image: { "docker-manifest-digest": `sha256:${"c".repeat(64)}` }, + }, + }, + ]) + ); + + expect(() => normalize(paths)).toThrow( + "Cosign verification is not bound to the candidate digest" + ); + }); +}); diff --git a/server/security/runtimeSchemaOwnership.config.test.ts b/server/security/runtimeSchemaOwnership.config.test.ts new file mode 100644 index 00000000..ad3a5edc --- /dev/null +++ b/server/security/runtimeSchemaOwnership.config.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relativePath: string) => + fs.readFileSync(path.join(root, relativePath), "utf8"); + +describe("runtime schema ownership and RLS source contract", () => { + it("registers DDL-only migration 0044 for feature flag columns and RLS policy state", () => { + const migration = read("drizzle/0044_runtime_schema_ownership_and_rls.sql"); + const journal = JSON.parse(read("drizzle/meta/_journal.json")) as { + entries: Array<{ idx: number; tag: string }>; + }; + + expect(journal.entries).toContainEqual({ + idx: 44, + version: "7", + when: 1788389839397, + tag: "0044_runtime_schema_ownership_and_rls", + breakpoints: true, + }); + expect(migration).toContain("ALTER TABLE public.feature_flags"); + expect(migration).toContain("ADD COLUMN IF NOT EXISTS strategy"); + expect(migration).toContain("ADD COLUMN IF NOT EXISTS parameters"); + expect(migration).toContain("ENABLE ROW LEVEL SECURITY"); + expect(migration).toContain("CREATE POLICY"); + expect(migration).not.toMatch(/INSERT\s+INTO|UPDATE\s+|DELETE\s+FROM/i); + }); + + it("removes active startup runtime DDL and requires migration readiness", () => { + const webhook = read("server/webhookSystem.ts"); + const featureFlags = read("server/featureFlags/index.ts"); + const marketplace = read("server/marketplace/index.ts"); + const startup = read("server/_core/index.ts"); + const ci = read(".github/workflows/ci.yml"); + const extendedE2E = read(".github/workflows/extended-e2e-matrix.yml"); + + expect(webhook).not.toMatch(/CREATE TABLE|CREATE INDEX|ALTER TABLE/); + expect(featureFlags).not.toMatch(/CREATE TABLE|CREATE INDEX|ALTER TABLE/); + expect(marketplace).not.toMatch(/CREATE TABLE|CREATE INDEX|ALTER TABLE/); + expect(webhook).toContain("Webhook migration 0029 is incomplete"); + expect(featureFlags).toContain("Feature flag migration 0044 is incomplete"); + expect(marketplace).toContain("Marketplace migration 0029 is incomplete"); + expect(startup).toContain("await initWebhookSystem(webhookPool)"); + expect(startup).toContain("await enableRowLevelSecurity()"); + expect(startup).toContain("await initMarketplace()"); + expect(startup).toContain("await initFeatureFlags()"); + expect(startup).not.toContain( + "Webhook system init failed — webhooks disabled" + ); + expect(startup).not.toContain("Multi-tenancy init skipped"); + expect(ci).toContain("name: Apply root Drizzle migrations"); + expect(ci).toContain("run: pnpm exec drizzle-kit migrate"); + expect(extendedE2E).toContain( + "name: Apply root Drizzle migrations to this disposable PostgreSQL database" + ); + expect(extendedE2E).toContain( + "pnpm exec drizzle-kit migrate > migration.log" + ); + expect(extendedE2E).not.toContain("drizzle-kit push --force"); + }); + + it("uses migration verification and parameterized tenant configuration", () => { + const multiTenancy = read("server/multiTenancy.ts"); + const featureFlags = read("server/featureFlags/index.ts"); + const marketplace = read("server/marketplace/index.ts"); + + expect(multiTenancy).not.toMatch(/ALTER TABLE\s+\$?\{?table|CREATE POLICY/); + expect(multiTenancy).toContain("RLS migration state is incomplete"); + expect(multiTenancy).toContain("$1::text"); + expect(multiTenancy).toContain("$2::text"); + expect(multiTenancy).toContain( + "SELECT set_config('app.current_org_id', $1, true)" + ); + expect(multiTenancy).toContain( + "SELECT set_config('app.is_admin', $1, true)" + ); + expect(featureFlags).toContain("stableRolloutBucket"); + expect(featureFlags).not.toContain("Math.random() * 100"); + expect(marketplace).toContain("active = true"); + expect(marketplace).toContain("last_delivery_at = NOW()"); + expect(marketplace).not.toContain("WHERE id = ${row.id}::uuid"); + }); +}); diff --git a/server/security/stagingProductionGoAcceptanceWorkflow.config.test.ts b/server/security/stagingProductionGoAcceptanceWorkflow.config.test.ts new file mode 100644 index 00000000..f6df917a --- /dev/null +++ b/server/security/stagingProductionGoAcceptanceWorkflow.config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("staging production-GO acceptance workflow source contract", () => { + it("is production-commit scoped and runs only in the protected staging environment", () => { + const workflow = read(".github/workflows/staging-production-go-acceptance.yml"); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("github.ref == 'refs/heads/production'"); + expect(workflow).toContain("COLLECT_PROTECTED_STAGING_GO_EVIDENCE"); + expect(workflow).toContain("name: staging"); + expect(workflow).toContain("[self-hosted, linux, ndsep-staging-internal]"); + expect(workflow).not.toContain("docker/build-push-action"); + expect(workflow).not.toContain("packages: write"); + }); + + it("requires a successful same-commit upstream staging acceptance artifact", () => { + const workflow = read(".github/workflows/staging-production-go-acceptance.yml"); + expect(workflow).toContain("Staging Integration Acceptance"); + expect(workflow).toContain(".headSha == $sha"); + expect(workflow).toContain('.headBranch == "production"'); + expect(workflow).toContain('.conclusion == "success"'); + expect(workflow).toContain('"staging-upstream-evidence-$SOURCE_COMMIT"'); + }); + + it("collects validated evidence through the anti-tampering collector only", () => { + const workflow = read(".github/workflows/staging-production-go-acceptance.yml"); + expect(workflow).toContain("collect-staging-release-evidence.mjs"); + expect(workflow).toContain("--confirm COLLECT_REAL_EVIDENCE"); + expect(workflow).toContain("name: staging-go-evidence-${{ github.sha }}"); + expect(workflow).not.toContain("gh pr review"); + }); +}); diff --git a/server/security/stagingReleaseEvidenceCollector.test.ts b/server/security/stagingReleaseEvidenceCollector.test.ts new file mode 100644 index 00000000..5b4c8b36 --- /dev/null +++ b/server/security/stagingReleaseEvidenceCollector.test.ts @@ -0,0 +1,589 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + collectStagingReleaseEvidence, + parseCollectorArguments, +} from "../../scripts/ci/collect-staging-release-evidence.mjs"; + +const DIGEST = "a".repeat(64); +const IMAGE = `ghcr.io/munisp/ndsep@sha256:${DIGEST}`; +const temporaryDirectories: string[] = []; + +async function createDirectory(prefix: string) { + const directory = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +async function writeJson(directory: string, filename: string, value: unknown) { + await writeFile( + join(directory, filename), + `${JSON.stringify(value, null, 2)}\n` + ); +} + +async function runCollectorCli(argumentsList: string[]) { + return new Promise<{ exitCode: number | null; stderr: string; stdout: string }>( + (resolve, reject) => { + const child = spawn(process.execPath, [ + join(process.cwd(), "scripts/ci/collect-staging-release-evidence.mjs"), + ...argumentsList, + ]); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", chunk => { + stdout += String(chunk); + }); + child.stderr?.on("data", chunk => { + stderr += String(chunk); + }); + child.once("error", reject); + child.once("close", exitCode => resolve({ exitCode, stderr, stdout })); + } + ); +} + +async function writeCompleteEvidence(directory: string) { + await writeJson(directory, "candidate.json", { + image: IMAGE, + sourceCommit: "b".repeat(40), + builtAt: "2026-08-31T20:00:00.000Z", + }); + await writeJson(directory, "trivy.json", { + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: [], + }); + await writeJson(directory, "artifact-evidence.json", { + sbom: { + imageDigest: DIGEST, + verified: true, + uri: "github://evidence/sbom.cdx.json", + sha256: "c".repeat(64), + format: "CycloneDX", + }, + provenance: { + imageDigest: DIGEST, + verified: true, + uri: "github://evidence/provenance.txt", + sha256: "d".repeat(64), + format: "SLSA", + }, + cosign: { + imageDigest: DIGEST, + verified: true, + uri: "github://evidence/cosign.json", + sha256: "e".repeat(64), + certificateIdentity: + "https://github.com/munisp/ndsep/.github/workflows/ci.yml@refs/heads/production", + }, + }); + await writeJson(directory, "governance.json", { + branch: "production", + requiredApprovals: 2, + requireCodeOwnerReviews: true, + productionReleaseEnvironment: { protected: true, preventSelfReview: true }, + }); + await writeJson(directory, "staging-deployment.json", { + status: "passed", + protectedEnvironment: true, + deploymentDigest: DIGEST, + mtlsContract: "passed", + authorizationNegativePath: "passed", + }); + await writeJson(directory, "service-matrix.json", { + services: [ + "postgresql", + "tigerbeetle", + "redis", + "mojaloop", + "kafka", + "apisix", + "keycloak", + "openappsec", + "permify", + "opensearch", + "fluvio", + ].map(name => ({ + name, + status: "passed", + digest: DIGEST, + evidenceUri: `github://evidence/services/${name}.json`, + })), + }); + await writeJson(directory, "postgres-integrity.json", { status: "passed" }); + await writeJson(directory, "resilience.json", { status: "passed" }); + await writeJson(directory, "residency-evidence.json", { status: "passed" }); + await writeJson(directory, "approvals.json", { + candidateDigest: DIGEST, + acceptances: [], + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map(directory => rm(directory, { recursive: true, force: true })) + ); +}); + +describe("staging release evidence collector", () => { + it("plans a candidate-bound collection without writing a local evidence bundle", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + + const result = collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }); + + expect(result).toMatchObject({ + status: "planned", + candidate: { image: IMAGE }, + }); + expect(result.files).toHaveLength(10); + expect(existsSync(output)).toBe(false); + }); + + it("writes a validated manifest and copies only validated upstream evidence after explicit collection", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + + const result = collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + write: true, + }); + + expect(result.status).toBe("collected"); + expect( + JSON.parse(await readFile(join(output, "candidate.json"), "utf8")) + ).toMatchObject({ image: IMAGE }); + const manifest = JSON.parse( + await readFile(join(output, "evidence-manifest.json"), "utf8") + ); + expect(manifest.files).toHaveLength(10); + expect(manifest.integrityNotice).toContain("does not perform deployments"); + expect((await stat(join(output, "candidate.json"))).mode & 0o777).toBe( + 0o600 + ); + expect((await stat(join(output, "evidence-manifest.json"))).mode & 0o777).toBe( + 0o600 + ); + }); + + it("rejects malformed upstream JSON without creating an output bundle", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeFile(join(source, "candidate.json"), "{ invalid-json\\n"); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + write: true, + }) + ).toThrow("candidate.json: must be valid JSON"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects a direct-image scan that reports a critical finding", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "trivy.json", { + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: [{ vulnerabilities: [{ severity: "CRITICAL" }] }], + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("trivy.json: found 1 HIGH/CRITICAL finding(s)"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects malformed supply-chain evidence before output creation", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "artifact-evidence.json", { + sbom: { + imageDigest: DIGEST, + verified: true, + uri: "github://evidence/sbom.cdx.json", + sha256: "c".repeat(64), + format: "CycloneDX", + }, + provenance: { + imageDigest: DIGEST, + verified: true, + uri: "github://evidence/provenance.txt", + sha256: "d".repeat(64), + format: "SLSA", + }, + cosign: { + imageDigest: DIGEST, + verified: true, + uri: "github://evidence/cosign.json", + sha256: "not-a-sha256", + certificateIdentity: + "https://github.com/munisp/ndsep/.github/workflows/ci.yml@refs/heads/production", + }, + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("artifact-evidence.json: cosign SHA-256 is required"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects a staging deployment bound to a different candidate digest", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "staging-deployment.json", { + status: "passed", + protectedEnvironment: true, + deploymentDigest: "f".repeat(64), + mtlsContract: "passed", + authorizationNegativePath: "passed", + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow( + "staging-deployment.json: deployment digest must equal candidate digest" + ); + }); + + it("rejects a core-service result without an evidence URI", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "service-matrix.json", { + services: [ + "postgresql", + "tigerbeetle", + "redis", + "mojaloop", + "kafka", + "apisix", + "keycloak", + "openappsec", + "permify", + "opensearch", + "fluvio", + ].map((name, index) => ({ + name, + status: "passed", + digest: DIGEST, + evidenceUri: + index === 0 ? "" : `github://evidence/services/${name}.json`, + })), + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("service-matrix.json: postgresql lacks evidenceUri"); + expect(existsSync(output)).toBe(false); + }); + + it("requires the explicit CLI confirmation token before write mode", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + + const rejected = await runCollectorCli([ + "--source-dir", + source, + "--out-dir", + output, + "--write", + ]); + expect(rejected.exitCode).toBe(1); + expect(rejected.stderr).toContain( + "--write requires --confirm COLLECT_REAL_EVIDENCE" + ); + expect(existsSync(output)).toBe(false); + + const accepted = await runCollectorCli([ + "--source-dir", + source, + "--out-dir", + output, + "--write", + "--confirm", + "COLLECT_REAL_EVIDENCE", + ]); + expect(accepted.exitCode).toBe(0); + expect(JSON.parse(accepted.stdout)).toMatchObject({ status: "collected" }); + expect(existsSync(join(output, "evidence-manifest.json"))).toBe(true); + }); + + it("rejects a non-canonical candidate timestamp before any write", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "candidate.json", { + image: IMAGE, + sourceCommit: "b".repeat(40), + builtAt: "2026-08-31", + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("candidate.json: builtAt must be a canonical ISO-8601 UTC timestamp"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects an invalid calendar timestamp even when it matches the UTC shape", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "candidate.json", { + image: IMAGE, + sourceCommit: "b".repeat(40), + builtAt: "2026-02-30T20:00:00.000Z", + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow( + "candidate.json: builtAt must be a canonical ISO-8601 UTC timestamp" + ); + expect(existsSync(output)).toBe(false); + }); + + it("rejects non-object upstream evidence records before field access", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "service-matrix.json", []); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("service-matrix.json: must be a JSON object"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects malformed nested Trivy result entries before any write", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "trivy.json", { + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: [{ target: "runtime" }], + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("trivy.json: results[0].vulnerabilities must be an array"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects malformed Trivy result collections before any write", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(source, "trivy.json", { + scanner: "trivy", + mode: "direct-image", + scanTarget: IMAGE, + results: { vulnerabilities: [] }, + }); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("trivy.json: results must be an array"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects symbolic links in upstream evidence inputs", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const alternate = await createDirectory("ndsep-staging-alternate-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await writeJson(alternate, "candidate.json", { + image: IMAGE, + sourceCommit: "b".repeat(40), + builtAt: "2026-08-31T20:00:00.000Z", + }); + await rm(join(source, "candidate.json")); + await symlink( + join(alternate, "candidate.json"), + join(source, "candidate.json") + ); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + }) + ).toThrow("candidate.json: source evidence must be a regular non-symlink file"); + expect(existsSync(output)).toBe(false); + }); + + it("rejects symbolic-link output paths before copying evidence", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const outputTarget = await createDirectory("ndsep-staging-output-target-"); + const output = join( + await createDirectory("ndsep-staging-output-parent-"), + "bundle" + ); + await writeCompleteEvidence(source); + await symlink(outputTarget, output); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + write: true, + }) + ).toThrow("output directory must not be a symbolic link"); + expect(existsSync(join(outputTarget, "evidence-manifest.json"))).toBe(false); + }); + + it("fails closed for incomplete, malformed, or unconfirmed CLI arguments", () => { + expect(() => parseCollectorArguments([])).toThrow( + "--source-dir and --out-dir are required" + ); + expect(() => + parseCollectorArguments([ + "--source-dir", + "/tmp/source", + "--out-dir", + "/tmp/output", + "--write", + "--confirm", + "wrong-token", + ]) + ).toThrow("--write requires --confirm COLLECT_REAL_EVIDENCE"); + expect(parseCollectorArguments([ + "--source-dir", + "/tmp/source", + "--out-dir", + "/tmp/output", + "--write", + "--confirm", + "COLLECT_REAL_EVIDENCE", + ])).toEqual({ + sourceDirectory: "/tmp/source", + outputDirectory: "/tmp/output", + write: true, + confirmation: "COLLECT_REAL_EVIDENCE", + }); + expect(() => + parseCollectorArguments([ + "--source-dir", + "/tmp/source", + "--out-dir", + "/tmp/output", + "--unexpected", + ]) + ).toThrow("Usage: collect-staging-release-evidence.mjs"); + }); + + it("refuses to mix a second evidence collection into a non-empty output directory", async () => { + const source = await createDirectory("ndsep-staging-source-"); + const output = await createDirectory("ndsep-staging-existing-output-"); + await writeCompleteEvidence(source); + await writeFile(join(output, "prior-evidence.txt"), "do not overwrite\n"); + + expect(() => + collectStagingReleaseEvidence({ + sourceDirectory: source, + outputDirectory: output, + write: true, + }) + ).toThrow("output directory must be empty to prevent mixing evidence runs"); + }); +}); diff --git a/server/security/stagingRollbackVerifier.config.test.ts b/server/security/stagingRollbackVerifier.config.test.ts new file mode 100644 index 00000000..710fc7b8 --- /dev/null +++ b/server/security/stagingRollbackVerifier.config.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), "utf8"); + +describe("staging rollback verifier source contract", () => { + it("is staging-scoped, candidate-bound, and requires immutable rollback identity", () => { + const script = read("scripts/security/verify-staging-rollback.ts"); + expect(script).toContain("STAGING_TEST_BASE_URL"); + expect(script).toContain("must use HTTPS"); + expect(script).toContain("must target a staging hostname"); + expect(script).toContain("STAGING_CANDIDATE_IMAGE_DIGEST"); + expect(script).toContain("STAGING_ROLLBACK_IMAGE_DIGEST"); + expect(script).toContain("sha256 immutable image digest"); + expect(script).toContain("STAGING_SOURCE_COMMIT must be a full commit SHA"); + expect(script).toContain("STAGING_ROLLBACK_DRILL_ID"); + }); + + it("uses a named read-only PostgreSQL principal and never mutates data or traffic", () => { + const script = read("scripts/security/verify-staging-rollback.ts"); + expect(script).toContain("STAGING_READONLY_DATABASE_URL"); + expect(script).toContain("STAGING_READONLY_DATABASE_USER"); + expect(script).toContain("STAGING_READONLY_DB_HOSTS"); + expect(script).toContain("BEGIN READ ONLY"); + expect(script).toContain("transaction_read_only"); + expect(script).toContain("has_table_privilege"); + expect(script).not.toMatch(/\b(?:INSERT|UPDATE|DELETE|ALTER|CREATE|DROP|TRUNCATE)\b[^\n]*(?:domain_event_outbox|payment_commands|webhook_deliveries|audit_logs)/i); + expect(script).not.toMatch(/\b(?:kubectl|helm|docker\s+(?:push|compose|build)|psql\s+-c)\b/i); + }); + + it("enforces every rollback smoke domain and emits a fail-closed sanitized report", () => { + const script = read("scripts/security/verify-staging-rollback.ts"); + for (const id of ["RB-01", "RB-02", "RB-03", "RB-04", "RB-05", "RB-06", "RB-07", "RB-08", "RB-09", "RB-10", "RB-11", "RB-12", "RB-13", "RB-14", "RB-15", "RB-16", "RB-17"]) { + expect(script).toContain(id); + } + expect(script).toContain("report.checks.length === 17"); + expect(script).toContain("domain_event_outbox"); + expect(script).toContain("payment_commands"); + expect(script).toContain("webhook_deliveries"); + expect(script).toContain("verifyAuditChain"); + expect(script).toContain("STAGING_ASYNC_RECONCILIATION_FILE"); + expect(script).toContain("STAGING_OBSERVABILITY_EVIDENCE_FILE"); + expect(script).toContain("STAGING_AUDIT_CHAIN_START_ID"); + expect(script).toContain("no backfill is permitted during verification"); + expect(script).toContain("rollback-verification.json"); + expect(script).toContain("process.exitCode = 1"); + }); + + it("requires the workflow to run only on the protected staging runner and retain evidence", () => { + const workflow = read(".github/workflows/staging-rollback-verification.yml"); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("name: staging"); + expect(workflow).toContain("[self-hosted, linux, ndsep-staging-internal]"); + expect(workflow).toContain("EXECUTE_STAGING_ROLLBACK_VERIFICATION"); + expect(workflow).toContain("STAGING_READONLY_DATABASE_URL"); + expect(workflow).toContain("STAGING_AUDIT_CHAIN_START_ID"); + expect(workflow).toContain("STAGING_ROLLBACK_EVIDENCE_DIR"); + expect(workflow).toContain("pnpm exec tsx scripts/security/verify-staging-rollback.ts"); + expect(workflow).toContain("if: always()"); + expect(workflow).toContain("retention-days: 365"); + expect(workflow).not.toContain("kubectl"); + expect(workflow).not.toContain("docker/build-push-action"); + expect(workflow).not.toContain("packages: write"); + }); +}); diff --git a/server/security/stagingSchemaDriftVerifier.test.ts b/server/security/stagingSchemaDriftVerifier.test.ts new file mode 100644 index 00000000..9b4d7d46 --- /dev/null +++ b/server/security/stagingSchemaDriftVerifier.test.ts @@ -0,0 +1,108 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + assertReadOnlyQueryPack, + assertSchemaComparison, + getVerifiedSchemaDriftSslConfig, + requireDistinctTargets, + validateSchemaDriftTarget, +} from "../../scripts/ci/verify-staging-schema-drift.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const queryPackPath = resolve(root, "scripts/ci/staging-schema-drift-and-synthetic-fixture-verification.sql"); +const workflowPath = resolve(root, ".github/workflows/ci.yml"); + +function passingReport() { + return { + transactionReadOnly: "on", + missingTables: [], + missingColumns: [], + unvalidatedConstraints: [], + invalidIndexes: [], + fingerprints: { + column: "a".repeat(32), + constraint: "b".repeat(32), + index: "c".repeat(32), + }, + }; +} + +describe("staging schema-drift verifier", () => { + it("requires distinct non-production staging/synthetic PostgreSQL targets with verified TLS", () => { + const baseline = validateSchemaDriftTarget( + "postgresql://reader:secret@baseline.example.invalid:5432/ndsep_staging_baseline?sslmode=verify-full", + "SCHEMA_DRIFT_BASELINE_DATABASE_URL" + ); + const staging = validateSchemaDriftTarget( + "postgresql://reader:secret@staging.example.invalid:5432/ndsep_staging_synthetic?sslmode=verify-full", + "SCHEMA_DRIFT_STAGING_DATABASE_URL" + ); + expect(() => requireDistinctTargets(baseline, staging)).not.toThrow(); + expect(() => validateSchemaDriftTarget( + "postgresql://reader:secret@staging.example.invalid:5432/ndsep_production?sslmode=verify-full", + "SCHEMA_DRIFT_STAGING_DATABASE_URL" + )).toThrow(/production- or live-labelled/); + expect(() => validateSchemaDriftTarget( + "postgresql://reader:secret@staging.example.invalid:5432/ndsep_staging_synthetic?sslmode=require", + "SCHEMA_DRIFT_STAGING_DATABASE_URL" + )).toThrow(/sslmode=verify-full/); + expect(() => requireDistinctTargets(baseline, baseline)).toThrow(/must be distinct/); + }); + + it("keeps certificate verification enabled and rejects malformed private CA bundles", () => { + expect(getVerifiedSchemaDriftSslConfig({})).toEqual({ rejectUnauthorized: true }); + expect(getVerifiedSchemaDriftSslConfig({ + SCHEMA_DRIFT_DB_SSL_CA_PEM: "-----BEGIN CERTIFICATE-----\nsynthetic-ca\n-----END CERTIFICATE-----", + })).toEqual({ + rejectUnauthorized: true, + ca: "-----BEGIN CERTIFICATE-----\nsynthetic-ca\n-----END CERTIFICATE-----", + }); + expect(() => getVerifiedSchemaDriftSslConfig({ SCHEMA_DRIFT_DB_SSL_CA_PEM: "not-a-pem" })).toThrow(/PEM certificate bundle/); + }); + + it("requires the tracked query pack to remain read-only and fingerprint-complete", async () => { + const sql = await readFile(queryPackPath, "utf8"); + expect(() => assertReadOnlyQueryPack(sql)).not.toThrow(); + expect(() => assertReadOnlyQueryPack([ + "BEGIN TRANSACTION READ ONLY;", + "SELECT 'schema_column_fingerprint';", + "SELECT 'schema_constraint_fingerprint';", + "SELECT 'schema_index_fingerprint';", + "WITH fixture_counts AS (SELECT 1)", + "INSERT INTO x VALUES (1);", + "ROLLBACK;", + ].join("\n"))).toThrow(/read-only/); + expect(() => assertReadOnlyQueryPack("BEGIN TRANSACTION READ ONLY; ROLLBACK;")).toThrow(/missing required marker/); + }); + + it("fails closed on schema fingerprint mismatch or invalid schema health", () => { + const baseline = passingReport(); + expect(() => assertSchemaComparison(baseline, passingReport())).not.toThrow(); + expect(() => assertSchemaComparison(baseline, { + ...passingReport(), + fingerprints: { ...passingReport().fingerprints, index: "d".repeat(32) }, + })).toThrow(/index fingerprint differs/); + expect(() => assertSchemaComparison(baseline, { + ...passingReport(), + missingTables: ["dt_simulations"], + })).toThrow(/missing required table/); + expect(() => assertSchemaComparison(baseline, { + ...passingReport(), + transactionReadOnly: "off", + })).toThrow(/not read-only/); + }); + + it("keeps the protected schema-drift job read-only, secret-scoped, and mandatory for publication", async () => { + const workflow = await readFile(workflowPath, "utf8"); + expect(workflow).toMatch( + /schema-drift-verification:\s*\n\s+name: Protected Staging Schema Drift Verification[\s\S]*?environment:\s*\n\s+name: production-release[\s\S]*?if: github\.ref == 'refs\/heads\/production'/ + ); + expect(workflow).toMatch(/schema-drift-verification:[\s\S]*?permissions:\s*\n\s+contents: read/); + expect(workflow).toMatch(/SCHEMA_DRIFT_BASELINE_DATABASE_URL: \$\{\{ secrets\.SCHEMA_DRIFT_BASELINE_DATABASE_URL \}\}/); + expect(workflow).toMatch(/SCHEMA_DRIFT_STAGING_DATABASE_URL: \$\{\{ secrets\.SCHEMA_DRIFT_STAGING_DATABASE_URL \}\}/); + expect(workflow).toMatch(/SCHEMA_DRIFT_DB_SSL_CA_PEM: \$\{\{ secrets\.SCHEMA_DRIFT_DB_SSL_CA_PEM \}\}/); + expect(workflow).toMatch(/node scripts\/ci\/verify-staging-schema-drift\.mjs > schema-drift-verification\.json/); + expect(workflow).toMatch(/docker:[\s\S]*?needs:\s*\[[\s\S]*?schema-drift-verification,[\s\S]*?\]/); + }); +}); diff --git a/server/security/syntheticFixtureMigrationAndCi.test.ts b/server/security/syntheticFixtureMigrationAndCi.test.ts new file mode 100644 index 00000000..e0b2bfda --- /dev/null +++ b/server/security/syntheticFixtureMigrationAndCi.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + REMAINING_FIXTURE_TABLES, + requireFixtureTablesPopulated, +} from "../../scripts/seed-remaining-synthetic-fixtures.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const schemaMigrationPath = resolve(root, "drizzle/0035_digital_twin_noc_runtime_schema.sql"); +const journalPath = resolve(root, "drizzle/meta/_journal.json"); +const fixtureSqlPath = resolve(root, "scripts/fixtures/seed_remaining_synthetic_fixtures.sql"); +const securityWorkflowPath = resolve(root, ".github/workflows/security-gate.yml"); +const fixtureRunnerPath = resolve(root, "scripts/seed-remaining-synthetic-fixtures.mjs"); + +function read(path: string): string { + return readFileSync(path, "utf8"); +} + +describe("synthetic fixture schema and CI controls", () => { + it("registers a DDL-only active migration for the digital-twin and NOC parent schemas", () => { + const migration = read(schemaMigrationPath); + const journal = JSON.parse(read(journalPath)) as { entries: Array<{ idx: number; tag: string }> }; + expect(journal.entries).toContainEqual({ + idx: 35, + version: "7", + when: 1788219175186, + tag: "0035_digital_twin_noc_runtime_schema", + breakpoints: true, + }); + expect(migration).not.toMatch(/^\s*(INSERT|UPDATE|DELETE|COPY)\b/im); + for (const table of [ + "dt_jurisdictions", + "dt_policies", + "dt_org_agents", + "dt_simulations", + "dt_simulation_results", + "dt_monte_carlo_stats", + "dt_policy_impacts", + "dt_sandboxes", + "noc_agent_memory", + ]) { + expect(migration).toMatch(new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\s*\\(`)); + } + }); + + it("ships an idempotent synthetic-only SQL extension for all 15 truly uncovered tables", () => { + const fixtureSql = read(fixtureSqlPath); + expect(REMAINING_FIXTURE_TABLES).toHaveLength(15); + expect(new Set(REMAINING_FIXTURE_TABLES).size).toBe(15); + for (const table of REMAINING_FIXTURE_TABLES) { + expect(fixtureSql).toMatch(new RegExp(`INSERT INTO ${table}\\s*\\(`)); + } + expect(read(fixtureRunnerPath)).toContain("SYNTHETIC_SEED_CONFIRMATION"); + expect(fixtureSql).toContain("https://example.invalid/"); + expect(fixtureSql).toContain("not_production_evidence"); + expect(fixtureSql).not.toMatch(/^\s*(COMMIT|ROLLBACK|START\s+TRANSACTION)\b/im); + }); + + it("fails if its intended 15-table fixture scope is not populated", () => { + expect(() => requireFixtureTablesPopulated({ emptyTables: ["dt_simulations", "noc_agent_memory"] })).toThrow( + "Synthetic fixture extension did not populate required table(s): dt_simulations, noc_agent_memory" + ); + expect(requireFixtureTablesPopulated({ emptyTables: ["unrelated_table"] })).toEqual({ + emptyTables: ["unrelated_table"], + }); + }); + + it("makes source-level fixture, governance, and collector tamper checks a dependency of the aggregate Security Gate", () => { + const workflow = read(securityWorkflowPath); + expect(workflow).toContain("evidence-contract-verification:"); + expect(workflow).toContain("name: evidence-contract-verification"); + expect(workflow).toContain("permissions:\n contents: read"); + expect(workflow).toContain("persist-credentials: false"); + for (const testPath of [ + "server/security/syntheticSeedSafety.test.ts", + "server/security/syntheticFixturePlan.test.ts", + "server/security/stagingReleaseEvidenceCollector.test.ts", + "server/security/releaseEvidenceNormalizer.test.ts", + "server/security/postMergeGovernanceVerifier.test.ts", + "server/security/releaseArtifactHardening.config.test.ts", + ]) { + expect(workflow).toContain(testPath); + } + expect(workflow).toContain("- evidence-contract-verification"); + expect(workflow).toContain('[[ "${{ needs.evidence-contract-verification.result }}" == "success" ]]'); + expect(workflow).toContain("SYNTHETIC_SEED_CONFIRMATION"); + expect(workflow).toContain("0035_digital_twin_noc_runtime_schema"); + }); +}); diff --git a/server/security/syntheticFixturePlan.test.ts b/server/security/syntheticFixturePlan.test.ts new file mode 100644 index 00000000..caffae44 --- /dev/null +++ b/server/security/syntheticFixturePlan.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateSyntheticFixtureCoverage, + loadSyntheticFixturePlan, + requireSyntheticFixtureScenario, + validateSyntheticFixturePlan, +} from "../../scripts/lib/synthetic-fixture-plan.mjs"; + +const manifestUrl = new URL("../../scripts/fixtures/uncovered-table-scenario-manifest.json", import.meta.url); + +async function loadManifest() { + return loadSyntheticFixturePlan(manifestUrl); +} + +describe("synthetic fixture-plan governance", () => { + it("classifies every currently identified static seed gap exactly once", async () => { + const manifest = await loadManifest(); + const { fixtureTables, exemptTables } = validateSyntheticFixturePlan(manifest); + expect(fixtureTables).toHaveLength(73); + expect(exemptTables).toHaveLength(5); + expect(new Set([...fixtureTables, ...exemptTables]).size).toBe(78); + }); + + it("permits only declared runtime-owned empty tables and never credits readiness", async () => { + const manifest = await loadManifest(); + const report = evaluateSyntheticFixtureCoverage( + { emptyTables: ["event_store", "field_encryption_status"] }, + manifest, + new Date("2026-08-31T00:00:00.000Z") + ); + expect(report).toEqual({ + allPublicTablesPopulated: false, + scenarioComplete: true, + missingFixtureTables: [], + runtimeOwnedEmptyTables: ["event_store", "field_encryption_status"], + unplannedEmptyTables: [], + expiredRuntimeExemptions: [], + noReadinessCredit: true, + }); + expect(requireSyntheticFixtureScenario(report)).toBe(report); + }); + + it("fails closed for an empty required fixture table or an unplanned empty table", async () => { + const manifest = await loadManifest(); + const report = evaluateSyntheticFixtureCoverage( + { emptyTables: ["ai_ethics_reviews", "a_table_missing_from_the_plan"] }, + manifest, + new Date("2026-08-31T00:00:00.000Z") + ); + expect(report.scenarioComplete).toBe(false); + expect(report.missingFixtureTables).toEqual(["ai_ethics_reviews"]); + expect(report.unplannedEmptyTables).toEqual(["a_table_missing_from_the_plan"]); + expect(() => requireSyntheticFixtureScenario(report)).toThrow( + "missing fixture: ai_ethics_reviews, unplanned empty table: a_table_missing_from_the_plan" + ); + }); + + it("fails closed when an exemption is expired even when its table is populated", async () => { + const manifest = await loadManifest(); + const report = evaluateSyntheticFixtureCoverage( + { emptyTables: [] }, + manifest, + new Date("2027-01-01T00:00:00.000Z") + ); + expect(report.scenarioComplete).toBe(false); + expect(report.expiredRuntimeExemptions).toHaveLength(5); + expect(() => requireSyntheticFixtureScenario(report)).toThrow("expired exemption: enforcement_summary"); + }); + + it("rejects a manifest that overlaps fixture requirements with a readiness-crediting exemption", () => { + expect(() => + validateSyntheticFixturePlan({ + schemaVersion: 1, + fixtureRequired: { events: "A detailed fixture strategy that meets the minimum description length." }, + runtimeOwnedExemptions: { + events: { + ownerRole: "SRE", + reason: "A detailed runtime ownership explanation that meets the minimum description length.", + expiresAt: "2026-12-31T00:00:00.000Z", + readinessCredit: true, + }, + }, + }) + ).toThrow("A table cannot be both fixture-required and runtime-exempt"); + }); +}); diff --git a/server/security/syntheticSeedSafety.test.ts b/server/security/syntheticSeedSafety.test.ts new file mode 100644 index 00000000..9eb0d9a4 --- /dev/null +++ b/server/security/syntheticSeedSafety.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + SYNTHETIC_SEED_CONFIRMATION, + SYNTHETIC_SEED_APPLICATION_NAME, + getSyntheticSeedPoolOptions, + inspectPublicTableCoverage, + requireCompleteSyntheticSeed, + summarizePublicTableCoverage, +} from "../../scripts/lib/synthetic-seed-safety.mjs"; + +const confirmedLocalEnvironment = { + NODE_ENV: "test", + SYNTHETIC_SEED_CONFIRMATION, + DATABASE_URL: "postgresql://seed_user:seed_password@localhost:5432/ndsep_synthetic", +}; + +describe("synthetic seed safety", () => { + it("requires explicit synthetic-data confirmation and never supplies a default database URL", () => { + expect(() => getSyntheticSeedPoolOptions({ NODE_ENV: "test" })).toThrow( + `Synthetic seed requires SYNTHETIC_SEED_CONFIRMATION=${SYNTHETIC_SEED_CONFIRMATION}` + ); + expect(() => + getSyntheticSeedPoolOptions({ + NODE_ENV: "test", + SYNTHETIC_SEED_CONFIRMATION, + }) + ).toThrow("Synthetic seed requires DATABASE_URL or POSTGRES_URL; no default database is permitted"); + }); + + it("rejects production-like, unlabelled, and remote targets by default", () => { + expect(() => + getSyntheticSeedPoolOptions({ + ...confirmedLocalEnvironment, + NODE_ENV: "production", + }) + ).toThrow("Synthetic seed is prohibited when NODE_ENV=production"); + expect(() => + getSyntheticSeedPoolOptions({ + ...confirmedLocalEnvironment, + DATABASE_URL: "postgresql://seed_user:seed_password@localhost:5432/ndsep_production", + }) + ).toThrow("Synthetic seed refuses a production- or live-labelled database name"); + expect(() => + getSyntheticSeedPoolOptions({ + ...confirmedLocalEnvironment, + DATABASE_URL: "postgresql://seed_user:seed_password@localhost:5432/ndsep", + }) + ).toThrow("Synthetic seed database name must identify a synthetic, demo, test, sandbox, or staging database"); + expect(() => + getSyntheticSeedPoolOptions({ + ...confirmedLocalEnvironment, + DATABASE_URL: "postgresql://seed_user:seed_password@db.internal:5432/ndsep_staging", + }) + ).toThrow("Synthetic seed refuses non-loopback host 'db.internal'"); + expect(() => + getSyntheticSeedPoolOptions({ + ...confirmedLocalEnvironment, + DATABASE_URL: "postgresql://seed_user:seed_password@db.internal:5432/ndsep_staging", + SYNTHETIC_SEED_ALLOW_REMOTE: SYNTHETIC_SEED_CONFIRMATION, + }) + ).toThrow("Synthetic seed requires sslmode=require, verify-ca, or verify-full for a remote target"); + }); + + it("returns bounded, explicitly labeled PostgreSQL pool options for an accepted target", () => { + expect(getSyntheticSeedPoolOptions(confirmedLocalEnvironment)).toMatchObject({ + connectionString: confirmedLocalEnvironment.DATABASE_URL, + application_name: SYNTHETIC_SEED_APPLICATION_NAME, + max: 2, + connectionTimeoutMillis: 5000, + statement_timeout: 30000, + query_timeout: 30000, + lock_timeout: 5000, + idle_in_transaction_session_timeout: 15000, + maxUses: 10000, + allowExitOnIdle: true, + }); + expect( + getSyntheticSeedPoolOptions({ + ...confirmedLocalEnvironment, + DATABASE_URL: "postgresql://seed_user:seed_password@staging-db.internal:5432/ndsep_staging?sslmode=verify-full", + SYNTHETIC_SEED_ALLOW_REMOTE: SYNTHETIC_SEED_CONFIRMATION, + SYNTHETIC_SEED_POOL_MAX: "3", + SYNTHETIC_SEED_STATEMENT_TIMEOUT_MS: "45000", + }) + ).toMatchObject({ max: 3, statement_timeout: 45000 }); + }); + + it("counts every public table and fails when even one remains empty", async () => { + const queried: string[] = []; + const client = { + query: async (statement: string) => { + queried.push(statement); + if (statement.includes("FROM pg_tables")) { + return { rows: [{ tablename: "assets" }, { tablename: "organizations" }, { tablename: "users" }] }; + } + if (statement.includes('"assets"')) return { rows: [{ count: "4" }] }; + if (statement.includes('"organizations"')) return { rows: [{ count: "2" }] }; + if (statement.includes('"users"')) return { rows: [{ count: "0" }] }; + throw new Error(`Unexpected statement: ${statement}`); + }, + }; + + const coverage = await inspectPublicTableCoverage(client); + expect(coverage).toEqual([ + { table: "assets", rows: 4 }, + { table: "organizations", rows: 2 }, + { table: "users", rows: 0 }, + ]); + const summary = summarizePublicTableCoverage(coverage); + expect(summary).toEqual({ + totalTables: 3, + populatedTables: 2, + totalRows: 6, + emptyTables: ["users"], + complete: false, + }); + expect(() => requireCompleteSyntheticSeed(summary)).toThrow( + "Synthetic seed coverage is incomplete; empty public tables: users" + ); + expect(queried).toHaveLength(4); + }); + + it("accepts a complete authoritative table-coverage report", () => { + const summary = summarizePublicTableCoverage([ + { table: "organizations", rows: 10 }, + { table: "users", rows: 3 }, + ]); + expect(requireCompleteSyntheticSeed(summary)).toMatchObject({ complete: true, totalTables: 2, totalRows: 13 }); + }); +}); diff --git a/server/security/themePreferencesDurableStorage.config.test.ts b/server/security/themePreferencesDurableStorage.config.test.ts new file mode 100644 index 00000000..eaaf286b --- /dev/null +++ b/server/security/themePreferencesDurableStorage.config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../.."); +const journal = JSON.parse(readFileSync(path.join(root, "drizzle/meta/_journal.json"), "utf8")); +const schema = readFileSync(path.join(root, "drizzle/schema.ts"), "utf8"); +const migration = readFileSync(path.join(root, "drizzle/0042_theme_preferences_durable_storage.sql"), "utf8"); +const router = readFileSync(path.join(root, "server/routers/phase7Features.ts"), "utf8"); +const rootRouter = readFileSync(path.join(root, "server/routers.ts"), "utf8"); +const themeContext = readFileSync(path.join(root, "client/src/contexts/ThemeContext.tsx"), "utf8"); + +describe("theme preference durable storage contract", () => { + it("registers a DDL-only theme-preferences migration and matching Drizzle model", () => { + expect(journal.entries).toContainEqual(expect.objectContaining({ + idx: 42, + tag: "0042_theme_preferences_durable_storage", + })); + expect(migration).toContain('CREATE TABLE "theme_preferences"'); + expect(migration).toContain('CONSTRAINT "theme_preferences_theme_check"'); + expect(migration).not.toMatch(/^\s*(INSERT|UPDATE|DELETE)\s+/im); + expect(schema).toContain('export const themePreferences = pgTable("theme_preferences"'); + expect(schema).toContain('userId: varchar("user_id", { length: 64 }).primaryKey()'); + }); + + it("uses parameterized durable writes and does not report a failed theme write as saved", () => { + expect(router).toContain('VALUES ($1, $2, NOW())'); + expect(router).toContain('theme = EXCLUDED.theme'); + expect(router).not.toContain("Silently ignore if table doesn't exist yet"); + expect(router).not.toContain("VALUES ('${userId}'"); + expect(router).not.toContain("CREATE TABLE IF NOT EXISTS changelogs"); + expect(router).toContain("WHERE org_id = $1"); + expect(router).toContain("WHERE sector = $1"); + expect(router).toContain("NOW() - ($2 * INTERVAL '1 day')"); + }); + + it("keeps user-scoped preferences protected without blocking an anonymous auth boundary", () => { + expect(rootRouter).toContain("me: publicProcedure.query(opts => opts.ctx.user ?? null)"); + expect(themeContext).toContain("const authQuery = trpc.auth.me.useQuery"); + expect(themeContext).toContain("enabled: isAuthenticated"); + expect(themeContext).toContain("if (!isAuthenticated) return;"); + }); +}); diff --git a/server/security/threatProtection.test.ts b/server/security/threatProtection.test.ts new file mode 100644 index 00000000..7897a565 --- /dev/null +++ b/server/security/threatProtection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { shouldSkipDdosSlowDown } from "./threatProtection"; + +function request(path: string, ip = "127.0.0.1") { + return { path, ip } as Parameters[0]; +} + +describe("DDoS slow-down scope", () => { + it("does not charge static PWA assets or browser routes against API throttling", () => { + expect(shouldSkipDdosSlowDown(request("/assets/index.js"))).toBe(true); + expect(shouldSkipDdosSlowDown(request("/sw.js"))).toBe(true); + expect(shouldSkipDdosSlowDown(request("/registerSW.js"))).toBe(true); + expect(shouldSkipDdosSlowDown(request("/organizations"))).toBe(true); + }); + + it("continues to protect dynamic API and OAuth endpoints", () => { + expect(shouldSkipDdosSlowDown(request("/api/trpc/auth.me"))).toBe(false); + expect(shouldSkipDdosSlowDown(request("/api/health"))).toBe(false); + expect(shouldSkipDdosSlowDown(request("/api/oauth/callback"))).toBe(false); + expect(shouldSkipDdosSlowDown(request("/oauth/authorize"))).toBe(false); + expect(shouldSkipDdosSlowDown(request("/login"))).toBe(false); + }); +}); diff --git a/server/security/threatProtection.ts b/server/security/threatProtection.ts index 438d559d..1a438a93 100644 --- a/server/security/threatProtection.ts +++ b/server/security/threatProtection.ts @@ -17,7 +17,23 @@ import { RateLimiterMemory, RateLimiterRes } from "rate-limiter-flexible"; // ── 1. Progressive DDoS slow-down ──────────────────────────────────────── /** - * After 50 requests/15s, add 500ms delay per request (up to 20s max). + * PWA asset and service-worker precache requests can legitimately exceed the + * threshold during a single initial page load. They are immutable static + * content and must be rate-limited at the CDN/WAF layer, not charged against + * application API throttling. Keep the application-layer delay on dynamic API + * and OAuth traffic where it protects database, session, and worker capacity. + */ +export function shouldSkipDdosSlowDown(req: Pick): boolean { + if (process.env.NODE_ENV === "development" && req.ip === "::1") return true; + return !( + req.path.startsWith("/api/") || + req.path.startsWith("/oauth") || + req.path === "/login" + ); +} + +/** + * After 50 dynamic requests/15s, add 500ms delay per request (up to 20s max). * This degrades the attacker's throughput without hard-blocking legitimate users. */ export const ddosSlowDown = slowDown({ @@ -25,7 +41,7 @@ export const ddosSlowDown = slowDown({ delayAfter: 50, // Start slowing after 50 req/window delayMs: (hits) => hits * 500, // 500ms × (hits - delayAfter) maxDelayMs: 20_000, // Cap at 20s - skip: (req) => process.env.NODE_ENV === "development" && req.ip === "::1", + skip: shouldSkipDdosSlowDown, }); // ── 2. Brute-force / credential stuffing protection ────────────────────── diff --git a/server/security/visualBaselineReminder.config.test.ts b/server/security/visualBaselineReminder.config.test.ts new file mode 100644 index 00000000..363c2c37 --- /dev/null +++ b/server/security/visualBaselineReminder.config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => + fs.readFileSync(path.join(root, relative), "utf8"); + +describe("visual baseline reminder source contract", () => { + it("defaults to a read-only plan and requires explicit confirmation for a send", () => { + const script = read("scripts/ci/remind-visual-baseline-reviewers.mjs"); + expect(script).toContain('const mode = arg(args, "--mode") ?? "plan"'); + expect(script).toContain( + 'const CONFIRMATION = "SEND_VISUAL_BASELINE_REVIEW_REMINDER"' + ); + expect(script).toContain('arg(args, "--confirm") !== CONFIRMATION'); + expect(script).toContain('mode === "plan" || recentMatching'); + }); + + it("only targets exactly four already-requested independent reviewers and deduplicates messages", () => { + const script = read("scripts/ci/remind-visual-baseline-reviewers.mjs"); + expect(script).toContain("const REQUIRED_REVIEWERS = 4"); + expect(script).toContain("reviewers.includes(pr.author?.login)"); + expect(script).toContain("refusing to notify unassigned reviewer(s)"); + expect(script).toContain("reviewRequests"); + expect(script).toContain("REMINDER_WINDOW_MS"); + expect(script).toContain("ndsep-visual-baseline-reminder"); + }); + + it("cannot approve, merge, update visual snapshots, or change repository policy", () => { + const script = read("scripts/ci/remind-visual-baseline-reviewers.mjs"); + expect(script).not.toContain('"pr", "review"'); + expect(script).not.toContain('"pr", "merge"'); + expect(script).not.toContain('"--update-snapshots"'); + expect(script).not.toContain('"workflow", "run"'); + expect(script).not.toContain('"api", "--method", "PATCH"'); + expect(script).not.toContain('"api", "--method", "PUT"'); + expect(script).not.toContain('"api", "--method", "DELETE"'); + expect(script).toMatch(/"api",\s*"--method",\s*"POST"/); + expect(script).toContain("issues/${prNumber}/comments"); + }); +}); diff --git a/server/security/visualBaselineReviewVerifier.config.test.ts b/server/security/visualBaselineReviewVerifier.config.test.ts new file mode 100644 index 00000000..b79b2f87 --- /dev/null +++ b/server/security/visualBaselineReviewVerifier.config.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "..", ".."); +const read = (relative: string) => + fs.readFileSync(path.join(root, relative), "utf8"); + +describe("visual baseline review verifier source contract", () => { + it("declares exactly the 12 existing Chromium visual contracts without approval evidence", () => { + const contract = JSON.parse(read("e2e/visual-baseline-contract.json")); + const pendingReview = JSON.parse(read("e2e/visual-baseline-review.json")); + expect(contract.schemaVersion).toBe(1); + expect(contract.project).toBe("chromium"); + expect(contract.matrix).toEqual({ workers: 1, retries: 0 }); + expect(contract.snapshots).toHaveLength(12); + expect( + new Set(contract.snapshots.map((item: { id: string }) => item.id)).size + ).toBe(12); + expect( + new Set( + contract.snapshots.map((item: { snapshot: string }) => item.snapshot) + ).size + ).toBe(12); + expect(pendingReview.status).toBe("PENDING_INDEPENDENT_HUMAN_REVIEW"); + expect(pendingReview.schemaVersion).toBe(2); + expect(pendingReview.captureCommit).toBe(""); + expect(pendingReview.pullRequest.captureCommit).toBe(""); + expect(pendingReview.snapshots).toHaveLength(12); + expect( + pendingReview.snapshots.every( + (item: { decision: string; sha256: string }) => + item.decision === "pending" && item.sha256 === "" + ) + ).toBe(true); + expect( + pendingReview.approvals.every( + (item: { decision: string; actor: string }) => + item.decision === "pending" && item.actor === "" + ) + ).toBe(true); + }); + + it("requires all approved snapshot hashes, a reviewed capture ancestor, and four distinct independent role approvals at the final source head", () => { + const script = read("scripts/ci/verify-visual-baseline-review.mjs"); + expect(script).toContain("VISUAL_REVIEW_HEAD_COMMIT"); + expect(script).toContain("event?.pull_request?.head?.sha !== candidate"); + expect(script).toContain("review?.captureCommit !== captureCommit"); + expect(script).toContain( + "verifyCaptureIsAncestor(repository, captureCommit, candidate)" + ); + expect(script).toContain( + "repos/${repository}/compare/${captureCommit}...${candidate}" + ); + expect(script).toContain("review.snapshots.length !== 12"); + expect(script).toContain('entry?.decision !== "accepted"'); + expect(script).toContain( + "sha256(file) !== reviewById.get(entry.id).sha256" + ); + for (const role of [ + "product-ux-owner", + "qa-lead", + "accessibility-owner", + "independent-engineering-reviewer", + ]) + expect(script).toContain(`"${role}"`); + expect(script).toContain("actors.has(approval.actor)"); + expect(script).toContain("review?.commit_id === candidate"); + expect(script).toContain( + "visual baseline captureCommit must be an ancestor of VISUAL_REVIEW_HEAD_COMMIT" + ); + }); + + it("is read-only and cannot update snapshots, reviews, pull requests, workflows, or deployments", () => { + const script = read("scripts/ci/verify-visual-baseline-review.mjs"); + expect(script).toContain('"api"'); + expect(script).toContain( + "repos/${repository}/pulls/${prNumber}/reviews?per_page=100" + ); + expect(script).not.toContain('"pr", "review"'); + expect(script).not.toContain('"pr", "merge"'); + expect(script).not.toContain('"workflow", "run"'); + expect(script).not.toContain('"--update-snapshots"'); + expect(script).not.toContain("writeFileSync"); + expect(script).not.toContain("rmSync"); + }); + + it("runs before the visual Playwright shard with PR read-only permissions and unchanged zero retries", () => { + const workflow = read(".github/workflows/extended-e2e-matrix.yml"); + expect(workflow).toContain("pull-requests: read"); + expect(workflow).toContain( + "VISUAL_REVIEW_HEAD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}" + ); + const gateOffset = workflow.indexOf( + "Require independently reviewed visual baseline" + ); + const runnerOffset = workflow.indexOf( + "Run release-grade E2E shard without retry masking" + ); + expect(gateOffset).toBeGreaterThan(-1); + expect(runnerOffset).toBeGreaterThan(gateOffset); + expect(workflow).toContain("--workers=1"); + expect(workflow).toContain("--retries=0"); + }); +}); diff --git a/server/security/webhookShadowMonitoring.config.test.ts b/server/security/webhookShadowMonitoring.config.test.ts new file mode 100644 index 00000000..ecb02057 --- /dev/null +++ b/server/security/webhookShadowMonitoring.config.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, "../.."); +const read = (path: string) => readFileSync(resolve(root, path), "utf8"); + +describe("webhook shadow monitoring contract", () => { + const migration = read( + "drizzle/0045_webhook_delivery_attempts_shadow_queue.sql" + ); + const journal = read("drizzle/meta/_journal.json"); + const schema = read("drizzle/schema.ts"); + const delivery = read("server/webhookDelivery.ts"); + const core = read("server/_core/index.ts"); + const alerts = read("infra/prometheus/alerts.yml"); + const dashboard = JSON.parse( + read("infra/grafana/dashboards/ndsep-webhook-shadow.json") + ); + const provisioning = read( + "infra/grafana/provisioning/dashboards/ndsep-webhook-shadow.yml" + ); + const compose = read("docker-compose.production.yml"); + const ci = read(".github/workflows/ci.yml"); + + it("owns the shadow queue through one root migration and matching Drizzle declaration", () => { + expect(journal).toContain('"idx": 45'); + expect(journal).toContain( + '"tag": "0045_webhook_delivery_attempts_shadow_queue"' + ); + expect(migration).toContain( + "CREATE TABLE IF NOT EXISTS webhook_delivery_attempts" + ); + expect(migration).toContain("idempotency_key varchar(160) NOT NULL UNIQUE"); + expect(migration).toContain( + "webhook_delivery_attempt_processing_claim_check" + ); + expect(schema).toContain('pgTable("webhook_delivery_attempts"'); + expect(schema).toContain("idempotencyKey"); + }); + + it("keeps shadow delivery durable and makes active admission explicitly asynchronous", () => { + expect(delivery).toContain( + 'export type WebhookDeliveryQueueMode = "disabled" | "shadow" | "active"' + ); + expect(delivery).toContain( + "await enqueueWebhookAttempt(pool, subscription, event, queueMode)" + ); + expect(delivery).toContain('return "queued";'); + expect(delivery).toMatch( + /await recordWebhookDeliveryAttempt\(\s*pool,\s*subscription,\s*event,\s*response\.status,\s*success,\s*attempt\s*\)/ + ); + expect(delivery).toContain( + 'await finalizeShadow("delivered", response.status, attempt + 1)' + ); + expect(delivery).toContain( + 'await finalizeShadow("dead", response.status, attempt + 1)' + ); + }); + + it("exports bounded aggregate metrics and alerts on queue persistence or reconciliation failures", () => { + for (const metric of [ + "ndsep_webhook_shadow_mode", + "ndsep_webhook_shadow_enqueued_total", + "ndsep_webhook_shadow_enqueue_errors_total", + "ndsep_webhook_shadow_finalizations_total", + "ndsep_webhook_shadow_finalization_errors_total", + "ndsep_webhook_shadow_last_enqueued_unixtime", + ]) { + expect(core).toContain(metric); + } + for (const alertMetric of [ + "ndsep_webhook_shadow_mode", + "ndsep_webhook_shadow_enqueued_total", + "ndsep_webhook_shadow_enqueue_errors_total", + "ndsep_webhook_shadow_finalizations_total", + "ndsep_webhook_shadow_finalization_errors_total", + ]) { + expect(alerts).toContain(alertMetric); + } + expect(alerts).toContain("WebhookShadowQueueIntentFailure"); + expect(alerts).toContain("WebhookShadowQueueFinalizationFailure"); + expect(alerts).toContain("WebhookShadowTerminalErrorRateHigh"); + expect(alerts).toContain("WebhookShadowMetricsUnavailable"); + expect(alerts).toContain("max(ndsep_webhook_shadow_mode) == 1"); + expect(alerts).toContain( + "max_over_time(ndsep_webhook_shadow_mode[2m]) == 1" + ); + expect(alerts).toContain( + 'sum(increase(ndsep_webhook_shadow_finalizations_total{outcome="dead"}[5m])) >= 5' + ); + }); + + it("provisions the dashboard with only actual shadow metrics and runs its real PostgreSQL integration in CI", () => { + expect(dashboard.uid).toBe("ndsep-webhook-shadow-v1"); + expect(JSON.stringify(dashboard)).toContain("ndsep_webhook_shadow_mode"); + expect(JSON.stringify(dashboard)).toContain( + "ndsep_webhook_shadow_finalization_errors_total" + ); + expect(provisioning).toContain("path: /var/lib/grafana/dashboards"); + expect(compose).toContain( + "./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro" + ); + expect(ci).toContain("WEBHOOK_SHADOW_QUEUE_TEST_DATABASE_URL"); + expect(ci).toContain( + 'DATABASE_URL="$WEBHOOK_SHADOW_QUEUE_TEST_DATABASE_URL" pnpm exec drizzle-kit migrate' + ); + }); +}); diff --git a/server/security/webhookWorkerCutover.config.test.ts b/server/security/webhookWorkerCutover.config.test.ts new file mode 100644 index 00000000..260a7d1a --- /dev/null +++ b/server/security/webhookWorkerCutover.config.test.ts @@ -0,0 +1,156 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, "../.."); +const read = (relativePath: string) => + readFileSync(resolve(root, relativePath), "utf8"); + +describe("webhook worker cutover contract", () => { + const migration = read( + "drizzle/0046_webhook_delivery_attempt_claim_leases.sql" + ); + const leaseWindowMigration = read( + "drizzle/0047_webhook_delivery_attempt_lease_window.sql" + ); + const journal = read("drizzle/meta/_journal.json"); + const nodeDispatcher = read("server/webhookDelivery.ts"); + const legacyWebhookFacade = read("server/webhookSystem.ts"); + const worker = read("workers/go/cmd/webhook_delivery/main.go"); + const workerTests = read( + "workers/go/cmd/webhook_delivery/main_integration_test.go" + ); + const workerSecurityTests = read( + "workers/go/cmd/webhook_delivery/main_test.go" + ); + const metrics = read("server/_core/index.ts"); + const template = read("infra/k8s/webhook-delivery-worker.yaml.tmpl"); + const renderer = read( + "scripts/ci/render-webhook-delivery-worker-manifest.sh" + ); + const scaledObject = read("infra/k8s/keda-scaledobjects.yaml"); + const alerts = read("infra/prometheus/alerts.yml"); + const dashboard = JSON.parse( + read("infra/grafana/dashboards/ndsep-webhook-delivery-worker.json") + ); + + it("owns queue claim, lease, and canonical outcome linkage through journaled root migrations", () => { + expect(journal).toContain('"idx": 46'); + expect(journal).toContain( + '"tag": "0046_webhook_delivery_attempt_claim_leases"' + ); + for (const required of [ + "claim_token uuid", + "claim_owner varchar(128)", + "claim_expires_at timestamptz", + "last_error text", + "queue_attempt_id bigint", + "webhook_deliveries_queue_attempt_id_fk", + "uq_webhook_deliveries_queue_attempt", + "webhook_delivery_attempt_processing_claim_check", + ]) { + expect(migration).toContain(required); + } + expect(journal).toContain('"idx": 47'); + expect(journal).toContain( + '"tag": "0047_webhook_delivery_attempt_lease_window"' + ); + expect(leaseWindowMigration).toContain( + "webhook_delivery_attempt_processing_lease_window_check" + ); + expect(leaseWindowMigration).toContain("claim_expires_at > claimed_at"); + }); + + it("uses a transaction-scoped SKIP LOCKED claim and token-guarded finalization without runtime DDL", () => { + expect(worker).toContain( + "db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})" + ); + expect(worker).toContain("FOR UPDATE OF a SKIP LOCKED"); + expect(worker).toContain("claim_token = $2::uuid"); + expect(worker).toContain("claim_expires_at = now() + $4::interval"); + expect(worker).toContain("AND claim_token = $2::uuid"); + expect(worker).toContain( + "ON CONFLICT (queue_attempt_id) WHERE queue_attempt_id IS NOT NULL DO NOTHING" + ); + expect(worker).toContain("if terminal {"); + expect(worker).toContain("func validateWebhookTarget"); + expect(worker).toContain("net.DefaultResolver.LookupNetIP"); + expect(worker).toContain( + "webhook destination resolved to a blocked network address" + ); + expect(workerSecurityTests).toContain("TestValidateWebhookTarget"); + expect(worker).not.toMatch(/CREATE\s+TABLE|ALTER\s+TABLE|CREATE\s+INDEX/i); + expect(workerTests).toContain( + "TestWorkerClaimsAndFinalizesExactlyOneCanonicalOutcomePostgres" + ); + expect(workerTests).toContain( + "TestWorkerClaimSkipsAlreadyLeasedAttemptPostgres" + ); + expect(workerTests).toContain( + "TestWorkerRecoversExpiredLeaseAndReclaimsPostgres" + ); + expect(workerTests).toContain( + "TestWorkerConcurrentClaimsAreMutuallyExclusivePostgres" + ); + expect(workerTests).toContain( + "TestWorkerRecordsCanonicalOutcomeOnlyAfterTerminalStatePostgres" + ); + expect(workerTests).toContain( + "TestWorkerCanonicalLedgerFailureRollsBackQueueFinalizationPostgres" + ); + expect(workerTests).toContain( + "TestWorkerFinalizesInactiveSubscriptionWithoutDeliveryPostgres" + ); + expect(workerTests).toContain( + "TestWorkerLeaseWindowConstraintRejectsNonFutureLeasePostgres" + ); + }); + + it("keeps active admission asynchronous and instruments it without receiver identifiers", () => { + expect(nodeDispatcher).toContain('"disabled" | "shadow" | "active"'); + expect(nodeDispatcher).toContain( + "await enqueueWebhookAttempt(pool, subscription, event, queueMode)" + ); + expect(nodeDispatcher).toContain('if (queueMode === "active") {'); + expect(nodeDispatcher).toContain( + "dependencies.queueMode ?? getWebhookDeliveryQueueMode()" + ); + expect(nodeDispatcher).toContain('org_id AS "organizationId"'); + expect(nodeDispatcher).toContain("events @> ARRAY[$2::text]"); + expect(legacyWebhookFacade).toContain( + "return dispatchEvent(event, data, orgId, { pool });" + ); + expect(legacyWebhookFacade).not.toContain("fetch(sub.url"); + expect(metrics).toContain("ndsep_webhook_queue_active_mode"); + expect(metrics).toContain("ndsep_webhook_queue_active_enqueued_total"); + }); + + it("requires a rendered immutable GHCR image and scales only on a one-value claimable queue query", () => { + expect(template).toContain("image: ${NDSEP_WEBHOOK_DELIVERY_IMAGE}"); + expect(template).toContain('value: "active"'); + expect(template).toContain("webhook-delivery-worker-db"); + expect(template).toContain('prometheus.io/scrape: "true"'); + expect(template).not.toMatch(/image:\s+.*:latest/); + expect(renderer).toContain("ghcr.io/munisp/ndsep@sha256:"); + expect(renderer).toContain("must be an approved immutable"); + expect(scaledObject).toContain("name: webhook-delivery-worker-keda"); + expect(scaledObject).toContain("type: postgresql"); + expect(scaledObject).toContain("connectionFromEnv: NDSEP_PG_SCALER_URL"); + expect(scaledObject).toContain("SELECT COALESCE(count(*), 0)::numeric"); + expect(scaledObject).toContain('targetQueryValue: "50"'); + for (const metric of [ + "ndsep_webhook_queue_worker_claimed_total", + "ndsep_webhook_queue_worker_delivered_total", + "ndsep_webhook_queue_worker_retried_total", + "ndsep_webhook_queue_worker_dead_total", + "ndsep_webhook_queue_worker_finalization_errors_total", + ]) { + expect(worker).toContain(metric); + expect(JSON.stringify(dashboard)).toContain(metric); + } + expect(alerts).toContain("WebhookActiveQueueAdmissionFailure"); + expect(alerts).toContain("WebhookActiveWorkerFinalizationFailure"); + expect(alerts).toContain("WebhookActiveWorkerMetricsUnavailable"); + expect(dashboard.uid).toBe("ndsep-webhook-delivery-worker-v1"); + }); +}); diff --git a/server/themePreferences.postgres.integration.test.ts b/server/themePreferences.postgres.integration.test.ts new file mode 100644 index 00000000..9848c981 --- /dev/null +++ b/server/themePreferences.postgres.integration.test.ts @@ -0,0 +1,144 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { Client } from "pg"; +import type { appRouter as AppRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +const databaseUrl = process.env.THEME_PREFERENCES_TEST_DATABASE_URL; + +function isDisposableLocalPostgresUrl(value: string): boolean { + const parsed = new URL(value); + const databaseName = parsed.pathname.replace(/^\//, "").toLowerCase(); + return ( + parsed.protocol === "postgresql:" + && ["127.0.0.1", "localhost"].includes(parsed.hostname) + && /(?:test|ci|synthetic|integration|e2e)/.test(databaseName) + ); +} + +const shouldRun = typeof databaseUrl === "string" && isDisposableLocalPostgresUrl(databaseUrl); + +// This test writes only to a caller-supplied disposable localhost database. +// Refuse rather than silently skip if a non-local target is supplied. +if (databaseUrl && !shouldRun) { + throw new Error("THEME_PREFERENCES_TEST_DATABASE_URL must identify a localhost disposable test database"); +} + +let appRouter: typeof AppRouter; +let originalDatabaseUrl: string | undefined; +let migration: string; + +function callerFor(openId: string) { + return appRouter.createCaller({ + req: {} as TrpcContext["req"], + res: {} as TrpcContext["res"], + user: { id: 1, openId, name: "Theme integration test user", role: "user" } as TrpcContext["user"], + }); +} + +describe.skipIf(!shouldRun)("theme preference PostgreSQL router integration", () => { + let client: Client; + const firstUser = "theme-postgres-integration-user-001"; + const secondUser = "theme-postgres-integration-user-002"; + + beforeAll(async () => { + if (!databaseUrl) throw new Error("THEME_PREFERENCES_TEST_DATABASE_URL is required"); + client = new Client({ connectionString: databaseUrl }); + await client.connect(); + await client.query('DROP TABLE IF EXISTS "theme_preferences" CASCADE'); + await client.query("DROP TABLE IF EXISTS compliance_score_history CASCADE"); + migration = await readFile(new URL("../drizzle/0042_theme_preferences_durable_storage.sql", import.meta.url), "utf8"); + await client.query(migration); + await client.query(` + CREATE TABLE compliance_score_history ( + org_id VARCHAR(255) NOT NULL, + sector VARCHAR(100) NOT NULL, + score NUMERIC(5,2) NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL + ) + `); + await client.query( + `INSERT INTO compliance_score_history (org_id, sector, score, recorded_at) + VALUES ($1, $2, $3, NOW()), ($4, $5, $6, NOW())`, + ["org-quoted-'--", "banking", 92.5, "other-org", "banking", 15], + ); + + // Load the real router only after the test-specific local database URL is + // active. This prevents the integration test from sharing the main Node CI + // schema and keeps the database module's process-level pool isolated. + originalDatabaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = databaseUrl; + vi.resetModules(); + ({ appRouter } = await import("./routers")); + }); + + afterAll(async () => { + if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalDatabaseUrl; + await client?.end(); + }); + + it("persists user-scoped theme and changelog cursor through the real protected procedures", async () => { + const caller = callerFor(firstUser); + + await expect(caller.themePrefs.get()).resolves.toEqual({ + theme: "light", + lastSeenChangelogVersion: null, + }); + await expect(caller.themePrefs.set({ theme: "dark" })).resolves.toEqual({ ok: true }); + await expect(caller.changelog.markSeen({ version: "2026.09.02" })).resolves.toEqual({ ok: true }); + await expect(caller.themePrefs.get()).resolves.toEqual({ + theme: "dark", + lastSeenChangelogVersion: "2026.09.02", + }); + + const stored = await client.query( + `SELECT user_id, theme, last_seen_changelog_version + FROM theme_preferences + WHERE user_id = $1`, + [firstUser], + ); + expect(stored.rows).toEqual([{ + user_id: firstUser, + theme: "dark", + last_seen_changelog_version: "2026.09.02", + }]); + }); + + it("enforces per-user uniqueness and the allowed durable theme values", async () => { + const caller = callerFor(secondUser); + await caller.themePrefs.set({ theme: "light" }); + + await expect(client.query( + `INSERT INTO theme_preferences (user_id, theme) + VALUES ($1, $2)`, + [secondUser, "dark"], + )).rejects.toThrow(); + await expect(client.query( + `INSERT INTO theme_preferences (user_id, theme) + VALUES ($1, $2)`, + ["theme-postgres-invalid-theme", "system"], + )).rejects.toThrow(); + }); + + it("binds sparkline identifiers and bounded day intervals as PostgreSQL parameters", async () => { + const caller = callerFor(firstUser); + + await expect(caller.sparkline.getHistory({ orgId: "org-quoted-'--", days: 30 })).resolves.toEqual([ + expect.objectContaining({ score: "92.50", sector: "banking" }), + ]); + await expect(caller.sparkline.getHistory({ orgId: "org-quoted-'--", days: 7.5 })).rejects.toThrow(); + }); + + it("propagates a durable-write failure instead of returning a false saved result", async () => { + await client.query('DROP TABLE "theme_preferences"'); + const caller = callerFor("theme-postgres-write-failure"); + + try { + await expect(caller.themePrefs.set({ theme: "dark" })).rejects.toThrow(); + await expect(caller.changelog.markSeen({ version: "2026.09.02" })).rejects.toThrow(); + } finally { + await client.query(migration); + } + }); +}); diff --git a/server/tigerbeetle.failClosed.test.ts b/server/tigerbeetle.failClosed.test.ts index bfd8e99d..0866ca9e 100644 --- a/server/tigerbeetle.failClosed.test.ts +++ b/server/tigerbeetle.failClosed.test.ts @@ -3,6 +3,7 @@ import { createTigerBeetleTransaction, getTigerBeetleBalance, tigerBeetleSmokeTest, + __test__, } from "./tigerbeetle"; describe("TigerBeetle fail-closed contract", () => { @@ -15,7 +16,7 @@ describe("TigerBeetle fail-closed contract", () => { await expect(createTigerBeetleTransaction({ orgId: "org-1", penaltyId: "penalty-1", - amountUsd: 10, + amount: 10, type: "penalty", })).rejects.toThrow("TigerBeetle proxy rejected transaction"); }); @@ -25,11 +26,35 @@ describe("TigerBeetle fail-closed contract", () => { await expect(createTigerBeetleTransaction({ orgId: "org-1", penaltyId: "penalty-2", - amountUsd: 10, + amount: 10, type: "penalty", })).rejects.toThrow("ECONNREFUSED"); }); + it("binds a transfer idempotency key to both participant accounts", () => { + const base = { + orgId: "bank-1", + penaltyId: "nip-1", + amount: 10, + currency: "NGN" as const, + type: "transfer" as const, + debitAccountId: "bank-1", + creditAccountId: "bank-2", + }; + expect(__test__.idempotencyKey(base)).not.toBe(__test__.idempotencyKey({ ...base, creditAccountId: "bank-3" })); + }); + + it("rejects an interbank transfer without both durable account identifiers", async () => { + await expect(createTigerBeetleTransaction({ + orgId: "bank-1", + penaltyId: "nip-1", + amount: 10, + currency: "NGN", + type: "transfer", + debitAccountId: "bank-1", + })).rejects.toThrow("creditAccountId"); + }); + it("does not convert an unavailable balance lookup into a null balance", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("missing", { status: 404 }))); await expect(getTigerBeetleBalance("org-1")).rejects.toThrow("TigerBeetle balance lookup failed"); diff --git a/server/tigerbeetle.ts b/server/tigerbeetle.ts index ac4f6bee..beabf9e1 100644 --- a/server/tigerbeetle.ts +++ b/server/tigerbeetle.ts @@ -1,33 +1,30 @@ +import { createHash } from "node:crypto"; import { logger } from "./logger"; + /** * TigerBeetle HTTP Client - * ───────────────────────────────────────────────────────────────────────────── - * Wraps the TigerBeetle HTTP proxy (port 8240) that is spawned by the - * tigerbeetle_ledger Go orchestration service. Financial state is durable only - * after TigerBeetle acknowledges it; proxy failures are propagated to callers. * - * Double-entry semantics: - * - Every penalty creates two ledger entries: DEBIT (org liability) + CREDIT (NDSEP revenue) - * - Every settlement creates a CREDIT on the org account (reducing liability) - * - Every escrow hold creates a HOLD entry pending dispute resolution + * Financial state is durable only after the trusted proxy acknowledges a valid, + * idempotency-bound request. There is no PostgreSQL success fallback here. */ - const TB_BASE = process.env.TIGERBEETLE_HTTP_URL ?? "http://localhost:8240"; const TB_TIMEOUT_MS = 5_000; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; let tbTransactions = 0; let tbErrors = 0; -const tbDegraded = 0; -export type TbTransactionType = "penalty" | "fine" | "settlement" | "refund" | "escrow"; +export type TbTransactionType = "penalty" | "fine" | "settlement" | "refund" | "escrow" | "transfer"; export interface TbTransaction { id?: string; orgId: string; penaltyId: string; - amountUsd: number; + amount: number; currency?: string; type: TbTransactionType; + debitAccountId?: string; + creditAccountId?: string; description?: string; issuedBy?: string; timestamp?: string; @@ -42,31 +39,67 @@ export interface TbTransactionResult { } export interface TbBalance { - orgId: string; - total_penalties_issued: number; - total_penalties_paid: number; - total_escrow_held: number; - total_refunds: number; - net_liability: number; + org_id: string; currency: string; - lastUpdated: string; + debits_posted: string; + credits_posted: string; + debits_pending: string; + credits_pending: string; + net_position: string; + last_updated: string; } -/** - * Create a double-entry ledger transaction in TigerBeetle. - * Throws when the durable TigerBeetle proxy rejects or cannot accept the transaction. - */ +function requireTrustedTigerBeetleTransport(): void { + if (IS_PRODUCTION && !TB_BASE.startsWith("https://")) { + throw new Error("TigerBeetle HTTP proxy must use HTTPS in production"); + } +} + +function validateTransaction(tx: TbTransaction): void { + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(tx.orgId)) throw new Error("Invalid TigerBeetle orgId"); + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(tx.penaltyId)) throw new Error("Invalid TigerBeetle penaltyId"); + if (!Number.isFinite(tx.amount) || tx.amount <= 0 || !Number.isSafeInteger(Math.round(tx.amount * 100))) { + throw new Error("TigerBeetle amount must be a finite positive currency amount"); + } + if (tx.currency && !/^(NGN|USD)$/.test(tx.currency)) throw new Error("TigerBeetle currency must be NGN or USD"); + if (tx.type === "transfer") { + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(tx.debitAccountId ?? "")) throw new Error("TigerBeetle transfer requires a valid debitAccountId"); + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(tx.creditAccountId ?? "")) throw new Error("TigerBeetle transfer requires a valid creditAccountId"); + } +} + +function idempotencyKey(tx: TbTransaction): string { + // Repeated delivery of the same business event must produce the same durable request key. + const canonical = JSON.stringify({ + id: tx.id ?? null, + orgId: tx.orgId, + penaltyId: tx.penaltyId, + amount: tx.amount.toFixed(2), + currency: tx.currency ?? "USD", + type: tx.type, + debitAccountId: tx.debitAccountId ?? null, + creditAccountId: tx.creditAccountId ?? null, + }); + return createHash("sha256").update(canonical).digest("hex"); +} + +/** Create a durable, idempotency-bound TigerBeetle ledger transaction. */ export async function createTigerBeetleTransaction(tx: TbTransaction): Promise { + validateTransaction(tx); + requireTrustedTigerBeetleTransport(); + const key = idempotencyKey(tx); try { const res = await fetch(`${TB_BASE}/transaction`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "Idempotency-Key": key, "X-NDSEP-Ledger-Request": key }, body: JSON.stringify({ org_id: tx.orgId, penalty_id: tx.penaltyId, - amount_usd: tx.amountUsd, + amount: tx.amount, currency: tx.currency ?? "USD", type: tx.type, + debit_account_id: tx.debitAccountId, + credit_account_id: tx.creditAccountId, description: tx.description ?? `${tx.type} for org ${tx.orgId}`, issued_by: tx.issuedBy ?? "system", timestamp: tx.timestamp ?? new Date().toISOString(), @@ -75,27 +108,27 @@ export async function createTigerBeetleTransaction(tx: TbTransaction): Promise ""); - throw new Error(`TigerBeetle proxy rejected transaction with HTTP ${res.status}: ${body}`); + throw new Error(`TigerBeetle proxy rejected transaction with HTTP ${res.status}: ${body.slice(0, 512)}`); + } + const data = await res.json() as { transaction_id?: unknown; ledger_entry_id?: unknown }; + if (typeof data.transaction_id !== "string" || typeof data.ledger_entry_id !== "string") { + throw new Error("TigerBeetle proxy acknowledgement is missing durable transaction identifiers"); } - const data = await res.json(); tbTransactions++; return { success: true, transactionId: data.transaction_id, ledgerEntryId: data.ledger_entry_id }; } catch (err: unknown) { tbErrors++; const errMsg = err instanceof Error ? err.message : String(err); - logger.error({ err: errMsg }, "[TigerBeetle] Durable transaction failed"); + logger.error({ err: errMsg, idempotencyKey: key }, "[TigerBeetle] Durable transaction failed"); throw err instanceof Error ? err : new Error(errMsg); } } -/** - * Get the ledger balance for an organisation. - */ -export async function getTigerBeetleBalance(orgId: string): Promise { +export async function getTigerBeetleBalance(orgId: string, currency: "NGN" | "USD" = "NGN"): Promise { + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(orgId)) throw new Error("Invalid TigerBeetle orgId"); + requireTrustedTigerBeetleTransport(); try { - const res = await fetch(`${TB_BASE}/balance/${encodeURIComponent(orgId)}`, { - signal: AbortSignal.timeout(TB_TIMEOUT_MS), - }); + const res = await fetch(`${TB_BASE}/balance/${encodeURIComponent(orgId)}?currency=${currency}`, { signal: AbortSignal.timeout(TB_TIMEOUT_MS) }); if (!res.ok) throw new Error(`TigerBeetle balance lookup failed with HTTP ${res.status}`); return await res.json() as TbBalance; } catch (error) { @@ -104,39 +137,26 @@ export async function getTigerBeetleBalance(orgId: string): Promise { } } -/** - * Health check — returns true if the TigerBeetle proxy is reachable. - */ export async function isTigerBeetleHealthy(): Promise { try { + requireTrustedTigerBeetleTransport(); const res = await fetch(`${TB_BASE}/health`, { signal: AbortSignal.timeout(2_000) }); return res.ok; - } catch { - return false; - } + } catch { return false; } } -/** - * Metrics for monitoring and health dashboard. - */ export function tigerbeetleMetrics() { - return { - url: TB_BASE, - transactions: tbTransactions, - errors: tbErrors, - degraded: tbDegraded, - }; + return { url: TB_BASE, transactions: tbTransactions, errors: tbErrors, productionTransportRequired: IS_PRODUCTION }; } -/** - * Smoke-test: verify the TigerBeetle proxy health without writing a test ledger event. - */ export async function tigerBeetleSmokeTest(): Promise<{ ok: boolean; latencyMs: number; error?: string }> { const start = Date.now(); try { const ok = await isTigerBeetleHealthy(); - return { ok, latencyMs: Date.now() - start, ...(ok ? {} : { error: "TigerBeetle proxy is unavailable" }) }; + return { ok, latencyMs: Date.now() - start, ...(ok ? {} : { error: "TigerBeetle proxy is unavailable or untrusted" }) }; } catch (error) { return { ok: false, latencyMs: Date.now() - start, error: error instanceof Error ? error.message : String(error) }; } } + +export const __test__ = { idempotencyKey, validateTransaction }; diff --git a/server/webhookDelivery.postgres.integration.test.ts b/server/webhookDelivery.postgres.integration.test.ts new file mode 100644 index 00000000..66c65686 --- /dev/null +++ b/server/webhookDelivery.postgres.integration.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Pool } from "pg"; + +import { recordWebhookDeliveryAttempt } from "./webhookDelivery"; + +const databaseUrl = process.env.WEBHOOK_LEDGER_TEST_DATABASE_URL; + +function isDisposableLocalPostgresUrl(value: string): boolean { + const parsed = new URL(value); + const databaseName = parsed.pathname.replace(/^\//, "").toLowerCase(); + return ( + parsed.protocol === "postgresql:" + && ["127.0.0.1", "localhost"].includes(parsed.hostname) + && /(?:test|ci|synthetic|integration)/.test(databaseName) + ); +} + +const shouldRun = typeof databaseUrl === "string" && isDisposableLocalPostgresUrl(databaseUrl); + +// The test never chooses a database itself. A supplied non-disposable target +// fails the suite rather than risking a write to a shared environment. +if (databaseUrl && !shouldRun) { + throw new Error("WEBHOOK_LEDGER_TEST_DATABASE_URL must identify a localhost disposable test database"); +} + +describe.skipIf(!shouldRun)("webhook delivery PostgreSQL integration", () => { + let pool: Pool; + + beforeAll(async () => { + pool = new Pool({ connectionString: databaseUrl, max: 1 }); + await pool.query("TRUNCATE TABLE webhook_deliveries, webhook_subscriptions RESTART IDENTITY CASCADE"); + await pool.query( + `INSERT INTO webhook_subscriptions (org_id, url, events, secret, active) + VALUES ($1, $2, $3::text[], $4, true)`, + [1, "https://receiver.invalid/ndsep", ["audit.completed"], "synthetic-webhook-secret"] + ); + }); + + afterAll(async () => { + await pool.end(); + }); + + it("persists a complete, canonical, successful delivery attempt in PostgreSQL", async () => { + const event = { + id: "47e36de4-8c2f-4d54-9ff8-9a845e4a4377", + type: "audit.completed", + data: { auditId: "audit-synthetic-001", outcome: "completed" }, + timestamp: "2026-09-01T00:00:00.000Z", + }; + + await recordWebhookDeliveryAttempt(pool, { id: 1 }, event, 202, true, 0); + + const { rows } = await pool.query( + `SELECT subscription_id, event, payload, response_status, response_body, attempt, success + FROM webhook_deliveries` + ); + + expect(rows).toEqual([ + { + subscription_id: 1, + event: "audit.completed", + payload: event, + response_status: 202, + response_body: null, + attempt: 1, + success: true, + }, + ]); + }); +}); diff --git a/server/webhookDelivery.test.ts b/server/webhookDelivery.test.ts index 30c889d6..a242e4d9 100644 --- a/server/webhookDelivery.test.ts +++ b/server/webhookDelivery.test.ts @@ -1,10 +1,57 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +import { recordWebhookDeliveryAttempt } from "./webhookDelivery"; describe("webhookDelivery", () => { - it("should export webhook delivery functions", async () => { - const mod = await import("./webhookDelivery"); - expect(mod).toBeDefined(); - const exports = Object.keys(mod); - expect(exports.length).toBeGreaterThan(0); + const event = { + id: "3c4c3e98-1a87-4e84-9b6a-056e50e72c8d", + type: "audit.completed", + data: { auditId: "audit-synthetic-001", outcome: "completed" }, + timestamp: "2026-09-01T00:00:00.000Z", + }; + + it("persists the canonical signed-event delivery audit record before a delivery can be reported", async () => { + const query = vi.fn().mockResolvedValue({ rowCount: 1 }); + + await recordWebhookDeliveryAttempt({ query } as never, { id: 17 }, event, 202, true, 0); + + expect(query).toHaveBeenCalledTimes(1); + const [sql, parameters] = query.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain("INSERT INTO webhook_deliveries"); + expect(sql).toContain("(subscription_id, event, payload, response_status, response_body, attempt, delivered_at, success)"); + expect(sql).not.toContain("event_type"); + expect(sql).not.toContain("event_id"); + expect(parameters).toEqual([ + 17, + "audit.completed", + JSON.stringify(event), + 202, + 1, + true, + ]); + expect(sql).toContain("NULL"); + }); + + it("records transport failure as an explicit null-status failed attempt", async () => { + const query = vi.fn().mockResolvedValue({ rowCount: 1 }); + + await recordWebhookDeliveryAttempt({ query } as never, { id: 17 }, event, null, false, 2); + + const [, parameters] = query.mock.calls[0] as [string, unknown[]]; + expect(parameters).toEqual([ + 17, + "audit.completed", + JSON.stringify(event), + null, + 3, + false, + ]); + }); + + it("propagates ledger failure so callers cannot report an unaudited delivery as successful", async () => { + const query = vi.fn().mockRejectedValue(new Error("webhook ledger unavailable")); + + await expect(recordWebhookDeliveryAttempt({ query } as never, { id: 17 }, event, 202, true, 0)) + .rejects.toThrow("webhook ledger unavailable"); }); }); diff --git a/server/webhookDelivery.ts b/server/webhookDelivery.ts index 2bc676a5..7ec33c3c 100644 --- a/server/webhookDelivery.ts +++ b/server/webhookDelivery.ts @@ -38,10 +38,38 @@ export interface WebhookSubscription { const MAX_RETRIES = 3; const RETRY_DELAYS = [1000, 5000, 30000]; // 1s, 5s, 30s +type WebhookShadowMetrics = { + enqueued: number; + activeEnqueued: number; + enqueueErrors: number; + finalizedDelivered: number; + finalizedDead: number; + finalizationErrors: number; + lastEnqueuedAtSeconds: number; +}; + +const webhookShadowMetrics: WebhookShadowMetrics = { + enqueued: 0, + activeEnqueued: 0, + enqueueErrors: 0, + finalizedDelivered: 0, + finalizedDead: 0, + finalizationErrors: 0, + lastEnqueuedAtSeconds: 0, +}; + +export function getWebhookShadowMetrics(): Readonly { + return { ...webhookShadowMetrics }; +} + let _pool: Pool | null = null; function getPool(): Pool { if (!_pool) { - _pool = new Pool({ connectionString: getDatabaseUrl(), ssl: getPgSslConfig(), max: 3 }); + _pool = new Pool({ + connectionString: getDatabaseUrl(), + ssl: getPgSslConfig(), + max: 3, + }); } return _pool; } @@ -50,16 +78,217 @@ function signPayload(payload: string, secret: string): string { return crypto.createHmac("sha256", secret).update(payload).digest("hex"); } -async function deliverWebhook( +/** + * Persist a delivery attempt in the canonical PostgreSQL webhook ledger. The + * receiver is given a stable event identifier so retries are intentionally + * at-least-once and can be deduplicated downstream. Response bodies are not + * persisted because they may contain untrusted or sensitive remote content. + */ +export async function recordWebhookDeliveryAttempt( + pool: Pick, + subscription: Pick, + event: WebhookEvent, + statusCode: number | null, + success: boolean, + attempt: number +): Promise { + await pool.query( + `INSERT INTO webhook_deliveries + (subscription_id, event, payload, response_status, response_body, attempt, delivered_at, success) + VALUES ($1, $2, $3::jsonb, $4, NULL, $5, NOW(), $6)`, + [ + subscription.id, + event.type, + JSON.stringify(event), + statusCode, + attempt + 1, + success, + ] + ); +} + +export type WebhookDeliveryQueueMode = "disabled" | "shadow" | "active"; +export type WebhookDeliveryOutcome = "delivered" | "queued" | "failed"; + +type WebhookQueryable = Pick; + +type WebhookDeliveryDependencies = { + pool?: WebhookQueryable; + fetchImpl?: typeof fetch; + sleep?: (milliseconds: number) => Promise; + queueMode?: WebhookDeliveryQueueMode; +}; + +type WebhookDispatchDependencies = Pick< + WebhookDeliveryDependencies, + "pool" | "queueMode" +> & { + eventId?: string; + now?: () => Date; +}; + +export function getWebhookDeliveryQueueMode( + value = process.env.WEBHOOK_DELIVERY_QUEUE_MODE +): WebhookDeliveryQueueMode { + const mode = value ?? "disabled"; + if (mode === "disabled" || mode === "shadow" || mode === "active") + return mode; + throw new Error( + "WEBHOOK_DELIVERY_QUEUE_MODE must be disabled, shadow, or active" + ); +} + +export function webhookDeliveryAttemptKey( + subscriptionId: number, + eventId: string +): string { + return crypto + .createHash("sha256") + .update(`${subscriptionId}:${eventId}`) + .digest("hex"); +} + +export async function enqueueWebhookAttempt( + pool: WebhookQueryable, + subscription: Pick, + event: WebhookEvent, + queueMode: Extract +): Promise { + const status = queueMode === "active" ? "pending" : "shadow"; + const result = await pool.query( + `INSERT INTO webhook_delivery_attempts + (subscription_id, event_id, event_type, payload, destination_url, status, idempotency_key) + VALUES ($1, $2::uuid, $3, $4::jsonb, $5, $6::varchar, $7) + ON CONFLICT (idempotency_key) DO NOTHING`, + [ + subscription.id, + event.id, + event.type, + JSON.stringify(event), + subscription.url, + status, + webhookDeliveryAttemptKey(subscription.id, event.id), + ] + ); + if (result.rowCount === 1) { + webhookShadowMetrics.enqueued += 1; + if (queueMode === "active") webhookShadowMetrics.activeEnqueued += 1; + webhookShadowMetrics.lastEnqueuedAtSeconds = Math.floor(Date.now() / 1000); + } +} + +// Compatibility alias for the committed shadow-only caller and tests. +export const enqueueWebhookShadowAttempt = ( + pool: WebhookQueryable, + subscription: Pick, + event: WebhookEvent +) => enqueueWebhookAttempt(pool, subscription, event, "shadow"); + +export async function finalizeWebhookShadowAttempt( + pool: WebhookQueryable, + subscriptionId: number, + eventId: string, + status: "delivered" | "dead", + responseCode: number | null, + attemptCount: number +): Promise { + const result = await pool.query( + `UPDATE webhook_delivery_attempts + SET status = $1::varchar, + attempt_count = GREATEST(attempt_count, $2), + last_response_code = $3, + delivered_at = CASE WHEN $1::varchar = 'delivered' THEN now() ELSE delivered_at END, + updated_at = now() + WHERE idempotency_key = $4 + AND status = 'shadow' + RETURNING id`, + [ + status, + attemptCount, + responseCode, + webhookDeliveryAttemptKey(subscriptionId, eventId), + ] + ); + if (result.rowCount !== 1) { + throw new Error( + `Webhook shadow attempt is missing or already finalized for event ${eventId}` + ); + } + if (status === "delivered") webhookShadowMetrics.finalizedDelivered += 1; + else webhookShadowMetrics.finalizedDead += 1; +} + +const defaultSleep = (milliseconds: number) => + new Promise(resolve => setTimeout(resolve, milliseconds)); + +export async function deliverWebhook( subscription: WebhookSubscription, event: WebhookEvent, - attempt: number = 0 -): Promise { + attempt = 0, + dependencies: WebhookDeliveryDependencies = {} +): Promise { + if (!subscription.active) { + logger.warn( + { subscriptionId: subscription.id, eventId: event.id }, + "[Webhook] Refusing inactive subscription" + ); + return "failed"; + } const payload = JSON.stringify(event); const signature = signPayload(payload, subscription.secret); + const pool = dependencies.pool ?? getPool(); + const queueMode = dependencies.queueMode ?? getWebhookDeliveryQueueMode(); + const fetchImpl = dependencies.fetchImpl ?? fetch; + const sleep = dependencies.sleep ?? defaultSleep; + + if ((queueMode === "shadow" || queueMode === "active") && attempt === 0) { + try { + await enqueueWebhookAttempt(pool, subscription, event, queueMode); + } catch (queueError) { + webhookShadowMetrics.enqueueErrors += 1; + logger.error( + { + err: + queueError instanceof Error + ? queueError.message + : String(queueError), + subscriptionId: subscription.id, + eventId: event.id, + }, + "[Webhook] Shadow queue intent could not be persisted" + ); + return "failed"; + } + } + + if (queueMode === "active") { + // Queue acceptance is distinct from delivery. The active worker owns dispatch and terminal state. + return "queued"; + } + + const finalizeShadow = async ( + status: "delivered" | "dead", + responseCode: number | null, + attempts: number + ) => { + if (queueMode !== "shadow") return; + try { + await finalizeWebhookShadowAttempt( + pool, + subscription.id, + event.id, + status, + responseCode, + attempts + ); + } catch (error) { + webhookShadowMetrics.finalizationErrors += 1; + throw error; + } + }; try { - const response = await fetch(subscription.url, { + const response = await fetchImpl(subscription.url, { method: "POST", headers: { "Content-Type": "application/json", @@ -73,72 +302,163 @@ async function deliverWebhook( }); const success = response.status >= 200 && response.status < 300; - - // Log delivery - const pool = getPool(); - await pool.query( - `INSERT INTO webhook_deliveries (subscription_id, event_type, event_id, status_code, success, attempt, created_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW())`, - [subscription.id, event.type, event.id, response.status, success, attempt + 1] - ).catch((e: unknown) => logger.debug({ err: e instanceof Error ? e.message : String(e) }, "fire-and-forget failed")); + try { + await recordWebhookDeliveryAttempt( + pool, + subscription, + event, + response.status, + success, + attempt + ); + } catch (ledgerError) { + logger.error( + { + err: + ledgerError instanceof Error + ? ledgerError.message + : String(ledgerError), + subscriptionId: subscription.id, + eventId: event.id, + }, + "[Webhook] Delivery response was received but durable audit logging failed" + ); + return "failed"; + } if (success) { - logger.info({ subscriptionId: subscription.id, eventType: event.type }, "[Webhook] Delivered"); - return true; + try { + await finalizeShadow("delivered", response.status, attempt + 1); + } catch (queueError) { + logger.error( + { + err: + queueError instanceof Error + ? queueError.message + : String(queueError), + subscriptionId: subscription.id, + eventId: event.id, + }, + "[Webhook] Canonical delivery was recorded but shadow finalization failed" + ); + return "failed"; + } + logger.info( + { subscriptionId: subscription.id, eventType: event.type }, + "[Webhook] Delivered" + ); + return "delivered"; } - // Retry on server errors if (response.status >= 500 && attempt < MAX_RETRIES - 1) { const delay = RETRY_DELAYS[attempt] ?? 30000; - logger.warn({ subscriptionId: subscription.id, status: response.status, attempt }, "[Webhook] Retrying in %dms", delay); - await new Promise(resolve => setTimeout(resolve, delay)); - return deliverWebhook(subscription, event, attempt + 1); + logger.warn( + { subscriptionId: subscription.id, status: response.status, attempt }, + "[Webhook] Retrying in %dms", + delay + ); + await sleep(delay); + return deliverWebhook(subscription, event, attempt + 1, dependencies); } - logger.error({ subscriptionId: subscription.id, status: response.status }, "[Webhook] Delivery failed"); - return false; + try { + await finalizeShadow("dead", response.status, attempt + 1); + } catch (queueError) { + logger.error( + { + err: + queueError instanceof Error + ? queueError.message + : String(queueError), + subscriptionId: subscription.id, + eventId: event.id, + }, + "[Webhook] Canonical failure was recorded but shadow finalization failed" + ); + return "failed"; + } + logger.error( + { subscriptionId: subscription.id, status: response.status }, + "[Webhook] Delivery failed" + ); + return "failed"; } catch (err) { - if (attempt < MAX_RETRIES - 1) { - const delay = RETRY_DELAYS[attempt] ?? 30000; - await new Promise(resolve => setTimeout(resolve, delay)); - return deliverWebhook(subscription, event, attempt + 1); + if (attempt >= MAX_RETRIES - 1) { + try { + await recordWebhookDeliveryAttempt( + pool, + subscription, + event, + null, + false, + attempt + ); + await finalizeShadow("dead", null, attempt + 1); + } catch (ledgerError) { + logger.error( + { + err: + ledgerError instanceof Error + ? ledgerError.message + : String(ledgerError), + subscriptionId: subscription.id, + eventId: event.id, + }, + "[Webhook] Durable audit logging failed for terminal transport error" + ); + } + logger.error( + { err, subscriptionId: subscription.id }, + "[Webhook] Delivery failed after %d attempts", + MAX_RETRIES + ); + return "failed"; } - logger.error({ err, subscriptionId: subscription.id }, "[Webhook] Delivery failed after %d attempts", MAX_RETRIES); - return false; + const delay = RETRY_DELAYS[attempt] ?? 30000; + await sleep(delay); + return deliverWebhook(subscription, event, attempt + 1, dependencies); } } export async function dispatchEvent( eventType: string, data: Record, - orgId?: number -): Promise<{ delivered: number; failed: number }> { - const pool = getPool(); + orgId?: number, + dependencies: WebhookDispatchDependencies = {} +): Promise<{ delivered: number; failed: number; queued: number }> { + const pool = dependencies.pool ?? getPool(); + const now = dependencies.now ?? (() => new Date()); const event: WebhookEvent = { - id: crypto.randomUUID(), + id: dependencies.eventId ?? crypto.randomUUID(), type: eventType, data, - timestamp: new Date().toISOString(), + timestamp: now().toISOString(), }; // Find matching subscriptions const { rows: subscriptions } = await pool.query( - `SELECT id, url, secret, events, active, organization_id + `SELECT id, url, secret, events, active, org_id AS "organizationId" FROM webhook_subscriptions WHERE active = true - AND ($1::int IS NULL OR organization_id = $1) - AND events @> $2::jsonb`, - [orgId ?? null, JSON.stringify([eventType])] + AND ($1::int IS NULL OR org_id = $1) + AND events @> ARRAY[$2::text]`, + [orgId ?? null, eventType] ); + const queueMode = dependencies.queueMode ?? getWebhookDeliveryQueueMode(); let delivered = 0; let failed = 0; + let queued = 0; for (const sub of subscriptions) { - const success = await deliverWebhook(sub as WebhookSubscription, event); - if (success) delivered++; + const outcome = await deliverWebhook(sub as WebhookSubscription, event, 0, { + pool, + queueMode, + }); + if (outcome === "delivered") delivered++; + else if (outcome === "queued") queued++; else failed++; } - return { delivered, failed }; + return { delivered, failed, queued }; } diff --git a/server/webhookShadowQueue.postgres.integration.test.ts b/server/webhookShadowQueue.postgres.integration.test.ts new file mode 100644 index 00000000..8ede44aa --- /dev/null +++ b/server/webhookShadowQueue.postgres.integration.test.ts @@ -0,0 +1,318 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Pool } from "pg"; + +import { + deliverWebhook, + dispatchEvent, + getWebhookShadowMetrics, + webhookDeliveryAttemptKey, +} from "./webhookDelivery"; + +const databaseUrl = process.env.WEBHOOK_SHADOW_QUEUE_TEST_DATABASE_URL; + +function isDisposableLocalPostgresUrl(value: string): boolean { + const parsed = new URL(value); + const databaseName = parsed.pathname.replace(/^\//, "").toLowerCase(); + return ( + parsed.protocol === "postgresql:" && + ["127.0.0.1", "localhost"].includes(parsed.hostname) && + /(?:test|ci|synthetic|integration)/.test(databaseName) + ); +} + +const shouldRun = + typeof databaseUrl === "string" && isDisposableLocalPostgresUrl(databaseUrl); + +if (databaseUrl && !shouldRun) { + throw new Error( + "WEBHOOK_SHADOW_QUEUE_TEST_DATABASE_URL must identify a localhost disposable test database" + ); +} + +const subscription = { + id: 1, + url: "https://receiver.invalid/ndsep", + secret: "synthetic-webhook-secret", + events: ["audit.completed"], + active: true, + organizationId: 1, +}; + +function event(id: string) { + return { + id, + type: "audit.completed", + data: { auditId: `audit-${id}`, outcome: "completed" }, + timestamp: "2026-09-03T00:00:00.000Z", + }; +} + +describe.skipIf(!shouldRun)( + "webhook shadow queue PostgreSQL integration", + () => { + let pool: Pool; + + beforeAll(async () => { + pool = new Pool({ connectionString: databaseUrl, max: 1 }); + await pool.query( + "TRUNCATE TABLE webhook_delivery_attempts, webhook_deliveries, webhook_subscriptions RESTART IDENTITY CASCADE" + ); + await pool.query( + `INSERT INTO webhook_subscriptions (org_id, url, events, secret, active) + VALUES ($1, $2, $3::text[], $4, true)`, + [1, subscription.url, subscription.events, subscription.secret] + ); + }); + + afterAll(async () => { + await pool.end(); + }); + + it("durably writes a shadow intent and canonical delivery outcome before reporting successful delivery", async () => { + const metricsBefore = getWebhookShadowMetrics(); + const payload = event("11111111-1111-4111-8111-111111111111"); + + const delivered = await deliverWebhook(subscription, payload, 0, { + pool, + queueMode: "shadow", + fetchImpl: vi + .fn() + .mockResolvedValue(new Response(null, { status: 202 })), + sleep: async () => undefined, + }); + + expect(delivered).toBe("delivered"); + const queue = await pool.query( + `SELECT status, attempt_count, last_response_code, delivered_at, idempotency_key + FROM webhook_delivery_attempts + WHERE subscription_id = $1 AND event_id = $2::uuid`, + [subscription.id, payload.id] + ); + expect(queue.rows).toHaveLength(1); + expect(queue.rows[0]).toMatchObject({ + status: "delivered", + attempt_count: 1, + last_response_code: 202, + idempotency_key: webhookDeliveryAttemptKey(subscription.id, payload.id), + }); + expect(queue.rows[0].delivered_at).not.toBeNull(); + + const ledger = await pool.query( + "SELECT response_status, success, attempt FROM webhook_deliveries WHERE subscription_id = $1 AND payload->>'id' = $2", + [subscription.id, payload.id] + ); + expect(ledger.rows).toEqual([ + { response_status: 202, success: true, attempt: 1 }, + ]); + expect(getWebhookShadowMetrics()).toMatchObject({ + enqueued: metricsBefore.enqueued + 1, + finalizedDelivered: metricsBefore.finalizedDelivered + 1, + finalizedDead: metricsBefore.finalizedDead, + enqueueErrors: metricsBefore.enqueueErrors, + finalizationErrors: metricsBefore.finalizationErrors, + }); + }); + + it("accepts active queue mode only after durable pending intent and does not synchronously deliver", async () => { + const metricsBefore = getWebhookShadowMetrics(); + const payload = event("44444444-4444-4444-8444-444444444444"); + const receiver = vi.fn(); + + const accepted = await deliverWebhook(subscription, payload, 0, { + pool, + queueMode: "active", + fetchImpl: receiver as unknown as typeof fetch, + sleep: async () => undefined, + }); + + expect(accepted).toBe("queued"); + expect(receiver).not.toHaveBeenCalled(); + const queue = await pool.query( + "SELECT status, attempt_count, claim_token, claim_owner, claim_expires_at FROM webhook_delivery_attempts WHERE event_id = $1::uuid", + [payload.id] + ); + expect(queue.rows).toEqual([ + { + status: "pending", + attempt_count: 0, + claim_token: null, + claim_owner: null, + claim_expires_at: null, + }, + ]); + const ledger = await pool.query( + "SELECT count(*)::int AS count FROM webhook_deliveries WHERE payload->>'id' = $1", + [payload.id] + ); + expect(ledger.rows).toEqual([{ count: 0 }]); + expect(getWebhookShadowMetrics().activeEnqueued).toBe( + metricsBefore.activeEnqueued + 1 + ); + + const duplicateOutcome = await deliverWebhook(subscription, payload, 0, { + pool, + queueMode: "active", + fetchImpl: receiver as unknown as typeof fetch, + sleep: async () => undefined, + }); + expect(duplicateOutcome).toBe("queued"); + expect(receiver).not.toHaveBeenCalled(); + const duplicateQueue = await pool.query( + "SELECT count(*)::int AS count FROM webhook_delivery_attempts WHERE event_id = $1::uuid", + [payload.id] + ); + expect(duplicateQueue.rows).toEqual([{ count: 1 }]); + expect(getWebhookShadowMetrics().activeEnqueued).toBe( + metricsBefore.activeEnqueued + 1 + ); + }); + + it("uses migration-owned org_id filtering for active dispatch and reports queue acceptance", async () => { + await pool.query( + `INSERT INTO webhook_subscriptions (org_id, url, events, secret, active) + VALUES ($1, $2, $3::text[], $4, true)`, + [ + 2, + "https://other-org.invalid/ndsep", + ["audit.completed"], + "other-org-secret", + ] + ); + const eventId = "66666666-6666-4666-8666-666666666666"; + + const result = await dispatchEvent( + "audit.completed", + { auditId: "org-scoped-dispatch" }, + 1, + { + pool, + queueMode: "active", + eventId, + now: () => new Date("2026-09-03T00:00:00.000Z"), + } + ); + + expect(result).toEqual({ delivered: 0, failed: 0, queued: 1 }); + const queue = await pool.query( + `SELECT s.org_id, a.status, a.event_id + FROM webhook_delivery_attempts a + JOIN webhook_subscriptions s ON s.id = a.subscription_id + WHERE a.event_id = $1::uuid`, + [eventId] + ); + expect(queue.rows).toEqual([ + { org_id: 1, status: "pending", event_id: eventId }, + ]); + }); + + it("refuses an inactive subscription before enqueue or synchronous receiver activity", async () => { + const payload = event("55555555-5555-4555-8555-555555555555"); + const receiver = vi.fn(); + const inactiveOutcome = await deliverWebhook( + { ...subscription, active: false }, + payload, + 0, + { + pool, + queueMode: "active", + fetchImpl: receiver as unknown as typeof fetch, + sleep: async () => undefined, + } + ); + expect(inactiveOutcome).toBe("failed"); + expect(receiver).not.toHaveBeenCalled(); + const queue = await pool.query( + "SELECT count(*)::int AS count FROM webhook_delivery_attempts WHERE event_id = $1::uuid", + [payload.id] + ); + expect(queue.rows).toEqual([{ count: 0 }]); + }); + + it("records terminal transport-partition failure as a dead shadow row without retry masking", async () => { + const metricsBefore = getWebhookShadowMetrics(); + const payload = event("22222222-2222-4222-8222-222222222222"); + const fetchPartition = vi + .fn() + .mockRejectedValue(new TypeError("simulated network partition")); + const noWait = vi.fn().mockResolvedValue(undefined); + + const delivered = await deliverWebhook(subscription, payload, 0, { + pool, + queueMode: "shadow", + fetchImpl: fetchPartition, + sleep: noWait, + }); + + expect(delivered).toBe("failed"); + expect(fetchPartition).toHaveBeenCalledTimes(3); + expect(noWait).toHaveBeenCalledTimes(2); + + const queue = await pool.query( + "SELECT status, attempt_count, last_response_code, delivered_at FROM webhook_delivery_attempts WHERE event_id = $1::uuid", + [payload.id] + ); + expect(queue.rows).toEqual([ + { + status: "dead", + attempt_count: 3, + last_response_code: null, + delivered_at: null, + }, + ]); + + const ledger = await pool.query( + "SELECT response_status, success, attempt FROM webhook_deliveries WHERE payload->>'id' = $1", + [payload.id] + ); + expect(ledger.rows).toEqual([ + { response_status: null, success: false, attempt: 3 }, + ]); + expect(getWebhookShadowMetrics()).toMatchObject({ + enqueued: metricsBefore.enqueued + 1, + finalizedDelivered: metricsBefore.finalizedDelivered, + finalizedDead: metricsBefore.finalizedDead + 1, + enqueueErrors: metricsBefore.enqueueErrors, + finalizationErrors: metricsBefore.finalizationErrors, + }); + }); + + it("returns failure and leaves a reconciliation-visible shadow intent when canonical ledger persistence fails", async () => { + const payload = event("33333333-3333-4333-8333-333333333333"); + let queryCount = 0; + const failoverAfterShadowIntent = { + query: async (...arguments_: Parameters) => { + queryCount += 1; + if (queryCount === 2) + throw new Error( + "simulated PostgreSQL failover during canonical ledger write" + ); + return pool.query(...arguments_); + }, + }; + + const delivered = await deliverWebhook(subscription, payload, 0, { + pool: failoverAfterShadowIntent, + queueMode: "shadow", + fetchImpl: vi + .fn() + .mockResolvedValue(new Response(null, { status: 202 })), + sleep: async () => undefined, + }); + + expect(delivered).toBe("failed"); + const orphanedShadowIntent = await pool.query( + `SELECT a.status, count(d.id)::int AS canonical_rows + FROM webhook_delivery_attempts a + LEFT JOIN webhook_deliveries d + ON d.subscription_id = a.subscription_id + AND d.payload->>'id' = a.event_id::text + WHERE a.event_id = $1::uuid + GROUP BY a.status`, + [payload.id] + ); + expect(orphanedShadowIntent.rows).toEqual([ + { status: "shadow", canonical_rows: 0 }, + ]); + }); + } +); diff --git a/server/webhookSystem.ts b/server/webhookSystem.ts index 3594b650..d146642e 100644 --- a/server/webhookSystem.ts +++ b/server/webhookSystem.ts @@ -9,7 +9,7 @@ import crypto from "crypto"; import { Pool } from "pg"; import { logger } from "./logger"; -import { handleError } from "./errorClassifier"; +import { dispatchEvent } from "./webhookDelivery"; export type WebhookEvent = | "breach.reported" | "breach.notified" | "breach.resolved" @@ -29,41 +29,17 @@ export interface WebhookSubscription { createdAt: Date; } -const WEBHOOK_TABLES_SQL = ` -CREATE TABLE IF NOT EXISTS webhook_subscriptions ( - id SERIAL PRIMARY KEY, - org_id INTEGER NOT NULL, - url TEXT NOT NULL, - events TEXT[] NOT NULL DEFAULT '{}', - secret TEXT NOT NULL, - active BOOLEAN DEFAULT true, - failure_count INTEGER DEFAULT 0, - last_delivery_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() -); -CREATE INDEX IF NOT EXISTS idx_webhook_org ON webhook_subscriptions(org_id); - -CREATE TABLE IF NOT EXISTS webhook_deliveries ( - id SERIAL PRIMARY KEY, - subscription_id INTEGER REFERENCES webhook_subscriptions(id), - event TEXT NOT NULL, - payload JSONB NOT NULL, - response_status INTEGER, - response_body TEXT, - attempt INTEGER DEFAULT 1, - delivered_at TIMESTAMPTZ DEFAULT NOW(), - success BOOLEAN DEFAULT false -); -CREATE INDEX IF NOT EXISTS idx_webhook_delivery_sub ON webhook_deliveries(subscription_id); -`; - export async function initWebhookSystem(pool: Pool): Promise { - try { - await pool.query(WEBHOOK_TABLES_SQL); - logger.info("[Webhooks] System initialized"); - } catch (err) { - handleError(err, { module: "webhooks", action: "init" }); - } + if (process.env.NODE_ENV === "test") return; + const result = await pool.query<{ table_name: string }>( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = ANY($1::text[])`, + [["webhook_subscriptions", "webhook_deliveries"]] + ); + const found = new Set(result.rows.map((row) => row.table_name)); + const missing = ["webhook_subscriptions", "webhook_deliveries"].filter((table) => !found.has(table)); + if (missing.length) throw new Error(`Webhook migration 0029 is incomplete; missing tables: ${missing.join(", ")}`); + logger.info("[Webhooks] Migration-owned schema verified"); } /** Register a new webhook subscription */ @@ -83,81 +59,17 @@ export async function registerWebhook( return { id: result.rows[0].id, secret }; } -/** Sign a webhook payload using HMAC-SHA256 */ -function signPayload(payload: string, secret: string): string { - return crypto.createHmac("sha256", secret).update(payload).digest("hex"); -} - -/** Deliver a webhook event to all matching subscriptions */ +/** + * Compatibility facade for older internal emitters. Delivery is delegated to the + * single durable dispatcher; this module must never create an alternate direct + * receiver path that can bypass queue admission, canonical ledger semantics, or + * the active worker's egress controls. + */ export async function deliverWebhookEvent( pool: Pool, event: WebhookEvent, data: Record, orgId?: number -): Promise<{ delivered: number; failed: number }> { - const query = orgId - ? `SELECT * FROM webhook_subscriptions WHERE active = true AND $1 = ANY(events) AND org_id = $2` - : `SELECT * FROM webhook_subscriptions WHERE active = true AND $1 = ANY(events)`; - const params = orgId ? [event, orgId] : [event]; - const subs = await pool.query(query, params); - - let delivered = 0; - let failed = 0; - - for (const sub of subs.rows) { - try { - const payload = JSON.stringify({ - event, - data, - timestamp: new Date().toISOString(), - webhookId: sub.id, - }); - const signature = signPayload(payload, sub.secret); - - const response = await fetch(sub.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-NDSEP-Signature": `sha256=${signature}`, - "X-NDSEP-Event": event, - "X-NDSEP-Delivery": crypto.randomUUID(), - }, - body: payload, - signal: AbortSignal.timeout(10_000), - }); - - const success = response.ok; - await pool.query( - `INSERT INTO webhook_deliveries (subscription_id, event, payload, response_status, success) - VALUES ($1, $2, $3, $4, $5)`, - [sub.id, event, data, response.status, success] - ); - - if (success) { - delivered++; - await pool.query( - `UPDATE webhook_subscriptions SET last_delivery_at = NOW(), failure_count = 0 WHERE id = $1`, - [sub.id] - ); - } else { - failed++; - await pool.query( - `UPDATE webhook_subscriptions SET failure_count = failure_count + 1 WHERE id = $1`, - [sub.id] - ); - } - } catch (err) { - failed++; - handleError(err, { module: "webhooks", subscriptionId: sub.id, event }); - // Disable after 10 consecutive failures - await pool.query( - `UPDATE webhook_subscriptions SET failure_count = failure_count + 1, - active = CASE WHEN failure_count >= 10 THEN false ELSE active END - WHERE id = $1`, - [sub.id] - ); - } - } - - return { delivered, failed }; +): Promise<{ delivered: number; failed: number; queued: number }> { + return dispatchEvent(event, data, orgId, { pool }); } diff --git a/server/workerEventAuth.test.ts b/server/workerEventAuth.test.ts new file mode 100644 index 00000000..924677ab --- /dev/null +++ b/server/workerEventAuth.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { createWorkerEventSignature, validateWorkerEventShape, verifyWorkerEventSignature, workerEventNonceKey } from "./workerEventAuth"; + +const secret = "a-test-only-secret-that-is-longer-than-thirty-two-characters"; +const timestamp = "1760000000000"; +const nonce = "0123456789abcdefghijklmnop"; +const body = Buffer.from(JSON.stringify({ event: "security.alert", data: { severity: "high" } })); + +describe("worker event authentication", () => { + it("accepts a current, correctly signed event", () => { + const signature = createWorkerEventSignature(secret, "siem-correlator", timestamp, nonce, body); + expect(verifyWorkerEventSignature(secret, { workerId: "siem-correlator", timestamp, nonce, signature, rawBody: body }, Number(timestamp))).toBeUndefined(); + }); + + it("rejects an altered payload even when identity metadata is unchanged", () => { + const signature = createWorkerEventSignature(secret, "siem-correlator", timestamp, nonce, body); + const altered = Buffer.from(JSON.stringify({ event: "security.alert", data: { severity: "critical" } })); + expect(verifyWorkerEventSignature(secret, { workerId: "siem-correlator", timestamp, nonce, signature, rawBody: altered }, Number(timestamp))).toContain("signature"); + }); + + it("rejects a stale replay window", () => { + const signature = createWorkerEventSignature(secret, "siem-correlator", timestamp, nonce, body); + expect(verifyWorkerEventSignature(secret, { workerId: "siem-correlator", timestamp, nonce, signature, rawBody: body }, Number(timestamp) + 300_001)).toContain("freshness"); + }); + + it("validates names, event data and a stable nonce storage key", () => { + expect(validateWorkerEventShape("siem-correlator", "security.alert", { severity: "high" })).toBeUndefined(); + expect(validateWorkerEventShape("", "security.alert", {})).toContain("identity"); + expect(validateWorkerEventShape("siem-correlator", "x", {})).toContain("event"); + expect(validateWorkerEventShape("siem-correlator", "security.alert", [])).toContain("object"); + expect(workerEventNonceKey("siem-correlator", nonce)).toBe(`ndsep:worker-event:nonce:siem-correlator:${nonce}`); + }); +}); diff --git a/server/workerEventAuth.ts b/server/workerEventAuth.ts new file mode 100644 index 00000000..104f460f --- /dev/null +++ b/server/workerEventAuth.ts @@ -0,0 +1,46 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +export const WORKER_EVENT_SIGNATURE_VERSION = "ndsep-worker-event-v1"; +const MAX_EVENT_AGE_MS = 5 * 60 * 1000; +const ALLOWED_EVENT = /^[a-z][a-z0-9_.:-]{2,127}$/; +const ALLOWED_NONCE = /^[A-Za-z0-9_-]{22,128}$/; + +export interface WorkerEventEnvelope { + workerId: string; + timestamp: string; + nonce: string; + signature: string; + rawBody: Buffer; +} + +export function validateWorkerEventShape(workerId: string, event: unknown, data: unknown): string | undefined { + if (!workerId || !ALLOWED_EVENT.test(workerId)) return "invalid worker identity"; + if (typeof event !== "string" || !ALLOWED_EVENT.test(event)) return "invalid event name"; + if (data === null || typeof data !== "object" || Array.isArray(data)) return "event data must be an object"; + return undefined; +} + +export function createWorkerEventSignature(secret: string, workerId: string, timestamp: string, nonce: string, rawBody: Buffer): string { + const bodyHash = createHash("sha256").update(rawBody).digest("hex"); + const signed = [WORKER_EVENT_SIGNATURE_VERSION, workerId, timestamp, nonce, bodyHash].join("\n"); + return createHmac("sha256", secret).update(signed).digest("hex"); +} + +export function verifyWorkerEventSignature(secret: string, envelope: WorkerEventEnvelope, now = Date.now()): string | undefined { + if (!secret || secret.length < 32) return "worker event authentication is not configured"; + if (!ALLOWED_NONCE.test(envelope.nonce)) return "invalid event nonce"; + if (!/^\d{13}$/.test(envelope.timestamp)) return "invalid event timestamp"; + const eventTime = Number(envelope.timestamp); + if (!Number.isSafeInteger(eventTime) || Math.abs(now - eventTime) > MAX_EVENT_AGE_MS) return "event timestamp outside accepted freshness window"; + if (!/^[a-f0-9]{64}$/i.test(envelope.signature)) return "invalid event signature encoding"; + const expected = Buffer.from(createWorkerEventSignature(secret, envelope.workerId, envelope.timestamp, envelope.nonce, envelope.rawBody), "hex"); + const actual = Buffer.from(envelope.signature, "hex"); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return "event signature verification failed"; + return undefined; +} + +export function workerEventNonceKey(workerId: string, nonce: string): string { + return `ndsep:worker-event:nonce:${workerId}:${nonce}`; +} + +export const WORKER_EVENT_MAX_AGE_MS = MAX_EVENT_AGE_MS; diff --git a/server/workerManager.ts b/server/workerManager.ts index dd079eba..7f86b400 100644 --- a/server/workerManager.ts +++ b/server/workerManager.ts @@ -53,6 +53,7 @@ export interface WorkerDef { const _rawDbUrl = getDatabaseUrl(); const DB_URL = _rawDbUrl.includes("sslmode=") ? _rawDbUrl : _rawDbUrl + "?sslmode=disable"; const RELAY_URL = process.env.WORKER_RELAY_URL ?? "http://localhost:3000/api/workers/event"; +const ENABLE_CANDIDATE_ML_FOUNDATION = process.env.NDSEP_ENABLE_CANDIDATE_ML_FOUNDATION === "true"; export const WORKER_DEFS: WorkerDef[] = [ { @@ -124,6 +125,21 @@ export const WORKER_DEFS: WorkerDef[] = [ "scikit-learn Random Forest risk classification + Isolation Forest anomaly detection. Runs predictions every 25 seconds.", technology: "Python · scikit-learn · numpy · Apache Sedona · Ray", }, + ...(ENABLE_CANDIDATE_ML_FOUNDATION ? [{ + id: "ml-foundation-candidate", + name: "CPU Candidate ML Foundation", + layer: "L6", + language: "Python" as const, + command: "python3", + args: [path.join(PYTHON_DIR, "ml_foundation", "service.py")], + port: 8251, + env: { + NDSEP_ML_PORT: "8251", + NDSEP_ML_MODEL_DIR: process.env.NDSEP_ML_MODEL_DIR ?? "/var/lib/ndsep-ml/models", + }, + description: "Signed synthetic-only CPU MLP and GraphSAGE candidate inference. Returns human-review support only; no automated enforcement.", + technology: "Python · PyTorch CPU · PyTorch Geometric · signed artifacts", + }] : []), { id: "siem-correlator", name: "SIEM Alert Correlator", @@ -548,39 +564,12 @@ export const WORKER_DEFS: WorkerDef[] = [ technology: "Python · Apache Iceberg REST · PostgreSQL", }, // ── Orchestration Services (Go + Python) ──────────────────────────────── - { - id: "api-gateway", - name: "APISIX API Gateway Service", - language: "Go", - layer: "INFRA", - command: path.join(BIN_DIR, "api_gateway"), - args: [], - port: 8130, - env: { - APISIX_ADMIN_URL: process.env.APISIX_ADMIN_URL ?? "http://localhost:9180", - APISIX_ADMIN_KEY: process.env.APISIX_ADMIN_KEY ?? "", - APISIX_ENABLED: process.env.APISIX_ENABLED ?? "true", - }, - description: "Syncs 30 NDSEP journey routes to APISIX Admin API v3. Graceful degradation to in-memory registry when APISIX is unreachable.", - technology: "Go · APISIX Admin API v3 · gorilla/mux", - }, - { - id: "event-bus", - name: "Kafka + Fluvio Event Bus", - language: "Go", - layer: "INFRA", - command: path.join(BIN_DIR, "event_bus"), - args: [], - port: 8160, - env: { - KAFKA_BROKERS: process.env.KAFKA_BROKERS ?? "localhost:9092", - KAFKA_ENABLED: process.env.KAFKA_ENABLED ?? "true", - FLUVIO_HTTP_URL: process.env.FLUVIO_HTTP_URL ?? "http://localhost:9003", - FLUVIO_ENABLED: process.env.FLUVIO_ENABLED ?? "true", - }, - description: "Produces events to 30 NDSEP Kafka topics via IBM/sarama SyncProducer. Publishes edge events to Fluvio via HTTP. Graceful degradation to stub mode.", - technology: "Go · Kafka (IBM/sarama) · Fluvio HTTP · gorilla/mux", - }, + // The legacy `api_gateway` worker is intentionally not registered here: it + // carried a compiled APISIX route map and is superseded by `apisix-manager`, + // which loads the PostgreSQL-authoritative gateway_routes registry. + // The legacy `event_bus` worker is intentionally not registered here: it + // had no durable delivery ledger and declared stub fallback behavior. The + // application `eventBus.ts` PostgreSQL transactional outbox owns event state. { id: "iam-service", name: "Keycloak + Permify IAM Service", @@ -590,15 +579,16 @@ export const WORKER_DEFS: WorkerDef[] = [ args: [], port: 8150, env: { - KEYCLOAK_URL: process.env.KEYCLOAK_URL ?? "http://localhost:8080", - KEYCLOAK_REALM: process.env.KEYCLOAK_REALM ?? "ndsep", - KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID ?? "ndsep-platform", + KEYCLOAK_URL: process.env.KEYCLOAK_URL ?? "", + KEYCLOAK_REALM: process.env.KEYCLOAK_REALM ?? "", + KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID ?? "", KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET ?? "", KEYCLOAK_ENABLED: process.env.KEYCLOAK_ENABLED ?? "true", - PERMIFY_URL: process.env.PERMIFY_URL ?? "http://localhost:3476", + PERMIFY_URL: process.env.PERMIFY_URL ?? "", + PERMIFY_TENANT: process.env.PERMIFY_TENANT ?? "", PERMIFY_ENABLED: process.env.PERMIFY_ENABLED ?? "true", }, - description: "Validates JWT bearer tokens via Keycloak token introspection. Checks permissions via Permify HTTP REST v1. Graceful degradation to local role-map fallback.", + description: "Validates JWT bearer tokens through Keycloak and delegates permission decisions to Permify; unavailable dependencies deny requests.", technology: "Go · Keycloak (gocloak v13) · Permify HTTP REST · gorilla/mux", }, { @@ -665,8 +655,9 @@ export const WORKER_DEFS: WorkerDef[] = [ LAKEHOUSE_CATALOG_URL: process.env.LAKEHOUSE_CATALOG_URL ?? "http://localhost:8181", LAKEHOUSE_S3_ENDPOINT: process.env.LAKEHOUSE_S3_ENDPOINT ?? "http://localhost:9000", LAKEHOUSE_S3_BUCKET: process.env.LAKEHOUSE_S3_BUCKET ?? "ndsep-lakehouse", - LAKEHOUSE_S3_ACCESS_KEY: process.env.LAKEHOUSE_S3_ACCESS_KEY ?? "minioadmin", - LAKEHOUSE_S3_SECRET_KEY: process.env.LAKEHOUSE_S3_SECRET_KEY ?? "minioadmin", + LAKEHOUSE_S3_ACCESS_KEY: process.env.LAKEHOUSE_S3_ACCESS_KEY ?? "", + LAKEHOUSE_S3_SECRET_KEY: process.env.LAKEHOUSE_S3_SECRET_KEY ?? "", + LAKEHOUSE_ENABLED: process.env.LAKEHOUSE_ENABLED === "true" ? "true" : "false", LAKEHOUSE_PORT: "8140", WORKER_DATABASE_URL: DB_URL, }, @@ -758,7 +749,7 @@ export const WORKER_DEFS: WorkerDef[] = [ WATCHLIST_PORT: "8133", WORKER_DATABASE_URL: DB_URL, RELAY_URL, - OFAC_API_KEY: process.env.OFAC_API_KEY ?? "demo-ofac-key", + OFAC_API_KEY: process.env.OFAC_API_KEY ?? "", UN_LIST_URL: process.env.UN_LIST_URL ?? "https://scsanctions.un.org/resources/xml/en/consolidated.xml", NFIU_API_ENDPOINT: process.env.NFIU_API_ENDPOINT ?? "https://api.nfiu.gov.ng/v1/watchlist", }, @@ -797,7 +788,7 @@ export const WORKER_DEFS: WorkerDef[] = [ RELAY_URL, BVN_API_ENDPOINT: process.env.BVN_API_ENDPOINT ?? "https://api.nibss-plc.org.ng/v1/bvn", NIN_API_ENDPOINT: process.env.NIN_API_ENDPOINT ?? "https://api.nimc.gov.ng/v1/nin", - NIBSS_API_KEY: process.env.NIBSS_API_KEY ?? "demo-nibss-key", + NIBSS_API_KEY: process.env.NIBSS_API_KEY ?? "", }, description: "Automated KYC document analysis: BVN/NIN verification via NIBSS/NIMC APIs, liveness detection, document OCR, PEP screening, adverse media checks. Implements CBN KYC Directive 2023.", technology: "Python · OpenCV · Tesseract OCR · FastAPI · NIBSS API · NIMC API", @@ -815,7 +806,7 @@ export const WORKER_DEFS: WorkerDef[] = [ WORKER_DATABASE_URL: DB_URL, RELAY_URL, CBN_PORTAL_ENDPOINT: process.env.CBN_PORTAL_ENDPOINT ?? "https://portal.cbn.gov.ng/api/v1", - CBN_API_KEY: process.env.CBN_API_KEY ?? "demo-cbn-key", + CBN_API_KEY: process.env.CBN_API_KEY ?? "", NFIU_GOAML_ENDPOINT: process.env.NFIU_GOAML_ENDPOINT ?? "https://goaml.nfiu.gov.ng/api/v1", }, description: "Generates and submits CBN regulatory reports: STR (Suspicious Transaction Reports), CTR (Currency Transaction Reports), AML quarterly returns. Submits to CBN portal and NFIU goAML system.", @@ -941,9 +932,16 @@ export const WORKER_DEFS: WorkerDef[] = [ command: path.join(BIN_DIR, "apisix_manager"), args: [], port: 8201, - env: { APISIX_PORT: "8201", WORKER_DATABASE_URL: DB_URL, WORKER_RELAY_URL: RELAY_URL, APISIX_ADMIN_URL: process.env.APISIX_ADMIN_URL ?? "http://localhost:9180" }, - description: "Manages APISIX API gateway routes and plugins for NDSEP services.", - technology: "Go · APISIX · REST API", + env: { + APISIX_MANAGER_PORT: "8201", + WORKER_DATABASE_URL: DB_URL, + WORKER_RELAY_URL: RELAY_URL, + APISIX_ADMIN_URL: process.env.APISIX_ADMIN_URL ?? "", + APISIX_ADMIN_KEY: process.env.APISIX_ADMIN_KEY ?? "", + APISIX_MANAGER_INTERNAL_AUTH_TOKEN: process.env.APISIX_MANAGER_INTERNAL_AUTH_TOKEN ?? "", + }, + description: "Synchronizes PostgreSQL-authoritative APISIX routes and durable synchronization evidence.", + technology: "Go · PostgreSQL · APISIX Admin API", }, { id: "bgp-live-monitor", diff --git a/workers/go/cmd/apisix_manager/main.go b/workers/go/cmd/apisix_manager/main.go index 77cf0e08..0848f397 100644 --- a/workers/go/cmd/apisix_manager/main.go +++ b/workers/go/cmd/apisix_manager/main.go @@ -1,297 +1,364 @@ -// NDSEP APISIX Dynamic Route Manager — Go Worker -// Port 8153 | Manages APISIX routes, upstreams, plugins for all NDSEP services -// Implements dynamic route registration, rate limiting, auth plugin management +// NDSEP APISIX Dynamic Route Manager — PostgreSQL-authoritative control plane. +// Port 8153. Active route policy is stored in gateway_routes; this service neither +// carries a compiled route registry nor reports successful synchronization unless +// APISIX responds successfully and the outcome is durably recorded. package main import ( + "context" + "crypto/subtle" + "database/sql" "encoding/json" + "errors" "fmt" "log" "net/http" "os" + "strings" "sync/atomic" "time" + + "github.com/google/uuid" + "github.com/lib/pq" ) +const serviceName = "ndsep-apisix-manager" + +type config struct { + port string + databaseURL string + apisixAdminURL string + apisixAdminKey string + internalAuthToken string +} + +type gatewayRoute struct { + ID string `json:"id"` + Name string `json:"name"` + URI string `json:"uri"` + Methods []string `json:"methods"` + Upstream string `json:"upstream"` + Plugins json.RawMessage `json:"plugins"` + JourneyID sql.NullString `json:"journey_id"` + Description sql.NullString `json:"description"` + Version int `json:"version"` +} + +type syncResult struct { + ID string `json:"id"` + Version int `json:"version"` + HTTPStatus int `json:"http_status,omitempty"` + Error string `json:"error,omitempty"` +} + var ( - PORT = getEnv("APISIX_MANAGER_PORT", "8153") - APISIX_ADMIN_URL = getEnv("APISIX_ADMIN_URL", "http://localhost:9180") - APISIX_ADMIN_KEY = os.Getenv("APISIX_ADMIN_KEY") - NDSEP_APP_URL = getEnv("NDSEP_APP_URL", "http://localhost:3000") + logger = log.New(os.Stdout, "[apisix-manager] ", log.LstdFlags|log.LUTC) + startTime = time.Now() + db *sql.DB + runtimeConfig config + routeCreateCount int64 + errorCount int64 ) -func getEnv(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v +func requiredEnv(name string) (string, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return "", fmt.Errorf("%s is required", name) } - return fallback + return value, nil } -var ( - routeCreateCount int64 - routeDeleteCount int64 - pluginUpdateCount int64 - upstreamCount int64 - errorCount int64 - startTime = time.Now() -) +func loadConfig() (config, error) { + databaseURL, err := requiredEnv("WORKER_DATABASE_URL") + if err != nil { + databaseURL, err = requiredEnv("DATABASE_URL") + if err != nil { + return config{}, errors.New("WORKER_DATABASE_URL or DATABASE_URL is required") + } + } + apisixAdminURL, err := requiredEnv("APISIX_ADMIN_URL") + if err != nil { + return config{}, err + } + if os.Getenv("NODE_ENV") == "production" && !strings.HasPrefix(apisixAdminURL, "https://") { + return config{}, errors.New("APISIX_ADMIN_URL must use https:// in production") + } + apisixAdminKey, err := requiredEnv("APISIX_ADMIN_KEY") + if err != nil { + return config{}, err + } + if len(apisixAdminKey) < 32 { + return config{}, errors.New("APISIX_ADMIN_KEY must contain at least 32 characters") + } + internalAuthToken, err := requiredEnv("APISIX_MANAGER_INTERNAL_AUTH_TOKEN") + if err != nil { + return config{}, err + } + if len(internalAuthToken) < 32 { + return config{}, errors.New("APISIX_MANAGER_INTERNAL_AUTH_TOKEN must contain at least 32 characters") + } + port := os.Getenv("APISIX_MANAGER_PORT") + if port == "" { + port = os.Getenv("APISIX_PORT") + } + if port == "" { + port = "8153" + } + return config{port: port, databaseURL: databaseURL, apisixAdminURL: strings.TrimRight(apisixAdminURL, "/"), apisixAdminKey: apisixAdminKey, internalAuthToken: internalAuthToken}, nil +} -// NDSEP APISIX route definitions -var NdsepRoutes = []map[string]interface{}{ - { - "id": "ndsep-trpc", - "uri": "/api/trpc/*", - "name": "NDSEP tRPC API", - "methods": []string{"GET", "POST", "OPTIONS"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{NDSEP_APP_URL: 1}, - }, - "plugins": map[string]interface{}{ - "limit-req": map[string]interface{}{ - "rate": 100, "burst": 50, "key": "consumer_name", - }, - "cors": map[string]interface{}{ - "allow_origins": os.Getenv("ALLOWED_ORIGINS"), - "allow_methods": "GET,POST,OPTIONS", - "allow_headers": "Content-Type,Authorization,X-NDSEP-Tenant", - }, - "prometheus": map[string]interface{}{}, - "request-id": map[string]interface{}{}, - }, - }, - { - "id": "ndsep-oauth-callback", - "uri": "/api/oauth/*", - "name": "NDSEP OAuth", - "methods": []string{"GET", "POST"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{NDSEP_APP_URL: 1}, - }, - "plugins": map[string]interface{}{ - "limit-req": map[string]interface{}{"rate": 20, "burst": 10, "key": "remote_addr"}, - }, - }, - { - "id": "ndsep-stripe-webhook", - "uri": "/api/stripe/*", - "name": "NDSEP Stripe Webhook", - "methods": []string{"POST"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{NDSEP_APP_URL: 1}, - }, - "plugins": map[string]interface{}{ - "limit-req": map[string]interface{}{"rate": 50, "burst": 20, "key": "remote_addr"}, - }, - }, - { - "id": "ndsep-dapr-bridge", - "uri": "/internal/dapr/*", - "name": "NDSEP Dapr Bridge", - "methods": []string{"GET", "POST"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{"http://localhost:8150": 1}, - }, - "plugins": map[string]interface{}{ - "ip-restriction": map[string]interface{}{ - "whitelist": []string{"127.0.0.1", "::1"}, - }, - }, - }, - { - "id": "ndsep-fluvio-relay", - "uri": "/internal/fluvio/*", - "name": "NDSEP Fluvio Relay", - "methods": []string{"GET", "POST"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{"http://localhost:8151": 1}, - }, - }, - { - "id": "ndsep-mojaloop", - "uri": "/internal/mojaloop/*", - "name": "NDSEP Mojaloop Adapter", - "methods": []string{"GET", "POST"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{"http://localhost:8152": 1}, - }, - }, - { - "id": "ndsep-metrics", - "uri": "/internal/metrics/*", - "name": "NDSEP Prometheus Metrics", - "methods": []string{"GET"}, - "upstream": map[string]interface{}{ - "type": "roundrobin", - "nodes": map[string]int{"http://localhost:9090": 1}, - }, - "plugins": map[string]interface{}{ - "ip-restriction": map[string]interface{}{ - "whitelist": []string{"127.0.0.1", "::1", "10.0.0.0/8"}, - }, - }, - }, +func openDatabase(ctx context.Context, databaseURL string) (*sql.DB, error) { + pool, err := sql.Open("postgres", databaseURL) + if err != nil { + return nil, err + } + pool.SetMaxOpenConns(10) + pool.SetMaxIdleConns(5) + pool.SetConnMaxLifetime(5 * time.Minute) + if err := pool.PingContext(ctx); err != nil { + pool.Close() + return nil, err + } + return pool, nil +} + +func requireInternalAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + provided := r.Header.Get("X-Internal-Auth") + if len(provided) != len(runtimeConfig.internalAuthToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(runtimeConfig.internalAuthToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } } -type bytesReader struct{ data []byte; pos int } -func (r *bytesReader) Read(p []byte) (n int, err error) { - if r.pos >= len(r.data) { return 0, fmt.Errorf("EOF") } - n = copy(p, r.data[r.pos:]); r.pos += n; return n, nil +func loadActiveRoutes(ctx context.Context, queryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +}) ([]gatewayRoute, error) { + rows, err := queryer.QueryContext(ctx, ` + SELECT id, name, uri, methods, upstream, plugins, journey_id, description, version + FROM gateway_routes + WHERE is_active = true + ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + routes := make([]gatewayRoute, 0) + for rows.Next() { + var route gatewayRoute + var plugins []byte + if err := rows.Scan(&route.ID, &route.Name, &route.URI, pq.Array(&route.Methods), &route.Upstream, &plugins, &route.JourneyID, &route.Description, &route.Version); err != nil { + return nil, err + } + if !json.Valid(plugins) { + return nil, fmt.Errorf("route %s has invalid PostgreSQL plugins JSON", route.ID) + } + route.Plugins = append(json.RawMessage(nil), plugins...) + routes = append(routes, route) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(routes) == 0 { + return nil, errors.New("no active gateway_routes are configured") + } + return routes, nil } -func (r *bytesReader) Close() error { return nil } -func apisixRequest(method, path string, body interface{}) (map[string]interface{}, error) { - var bodyReader *bytesReader - if body != nil { - b, _ := json.Marshal(body) - bodyReader = &bytesReader{data: b} - } - url := APISIX_ADMIN_URL + path - client := &http.Client{Timeout: 10 * time.Second} - var req *http.Request - if bodyReader != nil { - req, _ = http.NewRequest(method, url, bodyReader) +func apisixRequest(ctx context.Context, method, path string, body any) (int, error) { + var payload *strings.Reader + if body == nil { + payload = strings.NewReader("") } else { - req, _ = http.NewRequest(method, url, nil) + encoded, err := json.Marshal(body) + if err != nil { + return 0, err + } + payload = strings.NewReader(string(encoded)) + } + req, err := http.NewRequestWithContext(ctx, method, runtimeConfig.apisixAdminURL+path, payload) + if err != nil { + return 0, err } - req.Header.Set("X-API-KEY", APISIX_ADMIN_KEY) + req.Header.Set("X-API-KEY", runtimeConfig.apisixAdminKey) req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + response, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) if err != nil { - log.Printf("[APISIXManager] APISIX degraded: %v", err) - return map[string]interface{}{"degraded": true}, nil + return 0, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return response.StatusCode, fmt.Errorf("APISIX returned HTTP %d", response.StatusCode) } - defer resp.Body.Close() - var result map[string]interface{} - json.NewDecoder(resp.Body).Decode(&result) - return result, nil + return response.StatusCode, nil } -func handleSyncRoutes(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return +func toApisixRoute(route gatewayRoute) (map[string]any, error) { + var plugins map[string]any + if err := json.Unmarshal(route.Plugins, &plugins); err != nil { + return nil, fmt.Errorf("route %s plugins cannot be decoded: %w", route.ID, err) } - results := make([]map[string]interface{}, 0, len(NdsepRoutes)) - for _, route := range NdsepRoutes { - id := route["id"].(string) - result, err := apisixRequest("PUT", "/apisix/admin/routes/"+id, route) - if err != nil { - atomic.AddInt64(&errorCount, 1) - } else { - atomic.AddInt64(&routeCreateCount, 1) - } - results = append(results, map[string]interface{}{ - "id": id, - "result": result, - }) + return map[string]any{ + "id": route.ID, + "name": route.Name, + "uri": route.URI, + "methods": route.Methods, + "upstream": map[string]any{ + "type": "roundrobin", + "nodes": map[string]int{route.Upstream: 1}, + }, + "plugins": plugins, + }, nil +} + +func recordSyncAttempt(ctx context.Context, route gatewayRoute, status string, httpStatus int, syncErr error) error { + var errorMessage any + if syncErr != nil { + errorMessage = syncErr.Error() } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "synced": len(results), - "routes": results, - }) + var statusValue any + if httpStatus > 0 { + statusValue = httpStatus + } + _, err := db.ExecContext(ctx, ` + INSERT INTO gateway_route_sync_attempts (id, route_id, route_version, status, http_status, error_message) + VALUES ($1, $2, $3, $4::gateway_route_sync_status, $5, $6)`, + uuid.NewString(), route.ID, route.Version, status, statusValue, errorMessage) + return err } -func handleListRoutes(w http.ResponseWriter, r *http.Request) { - result, err := apisixRequest("GET", "/apisix/admin/routes", nil) +func syncRoute(ctx context.Context, route gatewayRoute) syncResult { + result := syncResult{ID: route.ID, Version: route.Version} + apisixRoute, err := toApisixRoute(route) + if err == nil { + result.HTTPStatus, err = apisixRequest(ctx, http.MethodPut, "/apisix/admin/routes/"+route.ID, apisixRoute) + } if err != nil { + result.Error = err.Error() atomic.AddInt64(&errorCount, 1) - // Return local route definitions as fallback - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "routes": NdsepRoutes, - "degraded": true, - }) - return + if recordErr := recordSyncAttempt(ctx, route, "failed", result.HTTPStatus, err); recordErr != nil { + result.Error = result.Error + "; failed to persist synchronization evidence: " + recordErr.Error() + } + return result } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) + if err := recordSyncAttempt(ctx, route, "succeeded", result.HTTPStatus, nil); err != nil { + result.Error = "APISIX accepted route but synchronization evidence was not persisted: " + err.Error() + atomic.AddInt64(&errorCount, 1) + return result + } + atomic.AddInt64(&routeCreateCount, 1) + return result } -func handlePluginUpdate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return +func syncRoutes(ctx context.Context) ([]syncResult, error) { + routes, err := loadActiveRoutes(ctx, db) + if err != nil { + return nil, err } - var req map[string]interface{} - json.NewDecoder(r.Body).Decode(&req) - routeID, _ := req["routeId"].(string) - if routeID == "" { - http.Error(w, "routeId required", http.StatusBadRequest) - return + results := make([]syncResult, 0, len(routes)) + failed := false + for _, route := range routes { + result := syncRoute(ctx, route) + if result.Error != "" { + failed = true + } + results = append(results, result) } - result, _ := apisixRequest("PATCH", "/apisix/admin/routes/"+routeID, req) - atomic.AddInt64(&pluginUpdateCount, 1) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": result}) + if failed { + return results, errors.New("one or more APISIX route synchronization attempts failed") + } + return results, nil } -func handleUpstreamCreate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return +func healthHandler(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + databaseHealthy := db != nil && db.PingContext(ctx) == nil + apisixStatus, apisixErr := apisixRequest(ctx, http.MethodGet, "/apisix/admin/routes", nil) + apisixHealthy := apisixErr == nil + status := http.StatusOK + if !databaseHealthy || !apisixHealthy { + status = http.StatusServiceUnavailable } - var req map[string]interface{} - json.NewDecoder(r.Body).Decode(&req) - id, _ := req["id"].(string) - if id == "" { - http.Error(w, "id required", http.StatusBadRequest) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "service": serviceName, + "status": map[bool]string{true: "healthy", false: "unavailable"}[status == http.StatusOK], + "database_healthy": databaseHealthy, + "apisix_healthy": apisixHealthy, + "apisix_http_status": apisixStatus, + "routes_synced": atomic.LoadInt64(&routeCreateCount), + "sync_errors": atomic.LoadInt64(&errorCount), + "uptime_seconds": time.Since(startTime).Seconds(), + }) +} + +func listRoutesHandler(w http.ResponseWriter, r *http.Request) { + routes, err := loadActiveRoutes(r.Context(), db) + if err != nil { + http.Error(w, "gateway route registry unavailable", http.StatusServiceUnavailable) return } - result, _ := apisixRequest("PUT", "/apisix/admin/upstreams/"+id, req) - atomic.AddInt64(&upstreamCount, 1) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": result}) + _ = json.NewEncoder(w).Encode(map[string]any{"routes": routes, "total": len(routes), "source_of_truth": "postgresql"}) } -func handleHealth(w http.ResponseWriter, r *http.Request) { +func syncHandler(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + results, err := syncRoutes(ctx) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "healthy", - "service": "ndsep-apisix-manager", - "version": "1.0.0", - "uptime": time.Since(startTime).Seconds(), - "routes": len(NdsepRoutes), - "config": map[string]string{"apisix_admin_url": APISIX_ADMIN_URL}, - }) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "results": results, "error": err.Error()}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "results": results}) } -func handleMetrics(w http.ResponseWriter, r *http.Request) { +func metricsHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") - fmt.Fprintf(w, "ndsep_apisix_routes_created_total %d\n", atomic.LoadInt64(&routeCreateCount)) - fmt.Fprintf(w, "ndsep_apisix_routes_deleted_total %d\n", atomic.LoadInt64(&routeDeleteCount)) - fmt.Fprintf(w, "ndsep_apisix_plugin_updates_total %d\n", atomic.LoadInt64(&pluginUpdateCount)) - fmt.Fprintf(w, "ndsep_apisix_upstreams_total %d\n", atomic.LoadInt64(&upstreamCount)) - fmt.Fprintf(w, "ndsep_apisix_errors_total %d\n", atomic.LoadInt64(&errorCount)) - fmt.Fprintf(w, "ndsep_apisix_uptime_seconds %.2f\n", time.Since(startTime).Seconds()) + _, _ = fmt.Fprintf(w, "ndsep_apisix_routes_synced_total %d\n", atomic.LoadInt64(&routeCreateCount)) + _, _ = fmt.Fprintf(w, "ndsep_apisix_sync_errors_total %d\n", atomic.LoadInt64(&errorCount)) + _, _ = fmt.Fprintf(w, "ndsep_apisix_uptime_seconds %.2f\n", time.Since(startTime).Seconds()) } func main() { - mux := http.NewServeMux() - mux.HandleFunc("/routes/sync", handleSyncRoutes) - mux.HandleFunc("/routes", handleListRoutes) - mux.HandleFunc("/plugins/update", handlePluginUpdate) - mux.HandleFunc("/upstreams/create", handleUpstreamCreate) - mux.HandleFunc("/health", handleHealth) - mux.HandleFunc("/metrics", handleMetrics) - - log.Printf("[APISIXManager] Starting NDSEP APISIX Manager on port %s", PORT) - log.Printf("[APISIXManager] APISIX Admin URL: %s | Routes: %d", APISIX_ADMIN_URL, len(NdsepRoutes)) + cfg, err := loadConfig() + if err != nil { + logger.Fatal(err) + } + runtimeConfig = cfg + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + db, err = openDatabase(ctx, cfg.databaseURL) + if err != nil { + logger.Fatalf("PostgreSQL route registry is required: %v", err) + } + defer db.Close() + if _, err := syncRoutes(ctx); err != nil { + logger.Fatalf("initial APISIX route synchronization failed: %v", err) + } + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler) + mux.HandleFunc("/metrics", metricsHandler) + mux.HandleFunc("/routes", requireInternalAuth(listRoutesHandler)) + mux.HandleFunc("/routes/sync", requireInternalAuth(syncHandler)) server := &http.Server{ - Addr: ":" + PORT, - Handler: mux, - ReadTimeout: 15 * time.Second, - WriteTimeout: 15 * time.Second, + Addr: ":" + cfg.port, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, } - if err := server.ListenAndServe(); err != nil { - log.Fatalf("[APISIXManager] Server error: %v", err) + logger.Printf("starting %s on :%s with PostgreSQL route registry", serviceName, cfg.port) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Fatalf("server failed: %v", err) } } diff --git a/workers/go/cmd/apisix_manager/main_test.go b/workers/go/cmd/apisix_manager/main_test.go new file mode 100644 index 00000000..f02c8392 --- /dev/null +++ b/workers/go/cmd/apisix_manager/main_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func setupRouteRegistryTest(t *testing.T) (*sql.DB, func()) { + t.Helper() + dsn := os.Getenv("APISIX_MANAGER_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("APISIX_MANAGER_TEST_DATABASE_URL is required for PostgreSQL integration tests") + } + if !strings.Contains(dsn, "127.0.0.1") && !strings.Contains(dsn, "localhost") { + t.Fatal("APISIX_MANAGER_TEST_DATABASE_URL must point to a local disposable PostgreSQL instance") + } + pool, err := openDatabase(context.Background(), dsn) + if err != nil { + t.Fatalf("open disposable PostgreSQL: %v", err) + } + migrationPath := filepath.Join("..", "..", "..", "..", "drizzle", "0041_apisix_durable_route_registry.sql") + migration, err := os.ReadFile(migrationPath) + if err != nil { + pool.Close() + t.Fatalf("read route registry migration: %v", err) + } + if _, err := pool.Exec(`DROP TABLE IF EXISTS gateway_route_sync_attempts CASCADE; DROP TABLE IF EXISTS gateway_routes CASCADE; DROP TYPE IF EXISTS gateway_route_sync_status;`); err != nil { + pool.Close() + t.Fatalf("reset disposable registry: %v", err) + } + if _, err := pool.Exec(string(migration)); err != nil { + pool.Close() + t.Fatalf("apply route registry migration: %v", err) + } + cleanup := func() { + _, _ = pool.Exec(`DROP TABLE IF EXISTS gateway_route_sync_attempts CASCADE; DROP TABLE IF EXISTS gateway_routes CASCADE; DROP TYPE IF EXISTS gateway_route_sync_status;`) + _ = pool.Close() + } + return pool, cleanup +} + +func insertTestRoute(t *testing.T, pool *sql.DB, id string) { + t.Helper() + _, err := pool.Exec(` + INSERT INTO gateway_routes (id, name, uri, methods, upstream, plugins, version) + VALUES ($1, 'Test route', '/api/test', ARRAY['POST'], 'http://ndsep-api:3000', '{}'::jsonb, 1)`, id) + if err != nil { + t.Fatalf("insert test route: %v", err) + } +} + +func TestLoadActiveRoutesRequiresPersistedConfiguration(t *testing.T) { + pool, cleanup := setupRouteRegistryTest(t) + defer cleanup() + if _, err := loadActiveRoutes(context.Background(), pool); err == nil { + t.Fatal("expected an empty durable registry to fail without a compiled route fallback") + } + insertTestRoute(t, pool, "test-route") + routes, err := loadActiveRoutes(context.Background(), pool) + if err != nil { + t.Fatalf("load persisted route: %v", err) + } + if len(routes) != 1 || routes[0].ID != "test-route" || routes[0].Upstream != "http://ndsep-api:3000" { + t.Fatalf("unexpected persisted routes: %#v", routes) + } +} + +func TestSyncRouteRecordsSuccessAndFailureDurably(t *testing.T) { + pool, cleanup := setupRouteRegistryTest(t) + defer cleanup() + insertTestRoute(t, pool, "test-route") + routes, err := loadActiveRoutes(context.Background(), pool) + if err != nil { + t.Fatalf("load persisted route: %v", err) + } + if len(routes) != 1 { + t.Fatal("expected one test route") + } + + var receivedKey string + successServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedKey = r.Header.Get("X-API-KEY") + if r.Method != http.MethodPut || r.URL.Path != "/apisix/admin/routes/test-route" { + t.Fatalf("unexpected APISIX request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + })) + defer successServer.Close() + db = pool + runtimeConfig = config{apisixAdminURL: successServer.URL, apisixAdminKey: strings.Repeat("k", 32), internalAuthToken: strings.Repeat("a", 32)} + result := syncRoute(context.Background(), routes[0]) + if result.Error != "" || result.HTTPStatus != http.StatusCreated || receivedKey != runtimeConfig.apisixAdminKey { + t.Fatalf("expected success and authenticated APISIX request, got %#v", result) + } + + failureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer failureServer.Close() + runtimeConfig.apisixAdminURL = failureServer.URL + result = syncRoute(context.Background(), routes[0]) + if result.Error == "" || result.HTTPStatus != http.StatusServiceUnavailable { + t.Fatalf("expected non-2xx APISIX failure, got %#v", result) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rows, err := pool.QueryContext(ctx, `SELECT status, http_status FROM gateway_route_sync_attempts ORDER BY attempted_at, id`) + if err != nil { + t.Fatalf("query durable synchronization evidence: %v", err) + } + defer rows.Close() + outcomes := make([]string, 0, 2) + for rows.Next() { + var status string + var httpStatus sql.NullInt64 + if err := rows.Scan(&status, &httpStatus); err != nil { + t.Fatalf("scan durable synchronization evidence: %v", err) + } + outcomes = append(outcomes, status) + } + if got := strings.Join(outcomes, ","); got != "succeeded,failed" { + t.Fatalf("expected durable success and failure evidence, got %s", got) + } +} + +func TestInternalAuthRejectsMissingToken(t *testing.T) { + runtimeConfig = config{internalAuthToken: strings.Repeat("a", 32)} + handler := requireInternalAuth(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodPost, "/routes/sync", nil) + response := httptest.NewRecorder() + handler(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("expected missing internal authorization to be rejected, got %d", response.Code) + } +} diff --git a/workers/go/cmd/fluvio_relay/main.go b/workers/go/cmd/fluvio_relay/main.go index b49b5f0e..044abecf 100644 --- a/workers/go/cmd/fluvio_relay/main.go +++ b/workers/go/cmd/fluvio_relay/main.go @@ -1,27 +1,27 @@ // NDSEP Fluvio Event Relay — Go Worker // Port 8151 | Bridges tRPC events to Fluvio streaming platform // Fluvio is a Rust-native, Kafka-compatible streaming platform -// Falls back to Kafka when Fluvio is unavailable +// Production delivery is fail closed when Fluvio is unavailable or unauthenticated. package main import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "log" "net/http" + "net/url" "os" + "strings" "sync/atomic" "time" ) var ( - PORT = getEnv("FLUVIO_RELAY_PORT", "8151") - FLUVIO_ENDPOINT = getEnv("FLUVIO_ENDPOINT", "localhost:9003") - FLUVIO_SC_HOST = getEnv("FLUVIO_SC_HOST", "localhost") - FLUVIO_SC_PORT = getEnv("FLUVIO_SC_PORT", "9003") - KAFKA_BROKER = getEnv("KAFKA_BROKER", "localhost:9092") - KAFKA_FALLBACK = getEnv("KAFKA_FALLBACK_ENABLED", "true") + PORT = getEnv("FLUVIO_RELAY_PORT", "8151") + FLUVIO_ENDPOINT = strings.TrimSpace(os.Getenv("FLUVIO_ENDPOINT")) ) func getEnv(key, fallback string) string { @@ -32,12 +32,11 @@ func getEnv(key, fallback string) string { } var ( - produceCount int64 - consumeCount int64 - topicCount int64 - fallbackCount int64 - errorCount int64 - startTime = time.Now() + produceCount int64 + consumeCount int64 + topicCount int64 + errorCount int64 + startTime = time.Now() ) // NDSEP Fluvio topics — one per compliance domain @@ -68,10 +67,10 @@ type ProduceRequest struct { } type ConsumeRequest struct { - Topic string `json:"topic"` - Partition int `json:"partition,omitempty"` - Offset int64 `json:"offset,omitempty"` - MaxRecords int `json:"maxRecords,omitempty"` + Topic string `json:"topic"` + Partition int `json:"partition,omitempty"` + Offset int64 `json:"offset,omitempty"` + MaxRecords int `json:"maxRecords,omitempty"` } type TopicCreateRequest struct { @@ -81,48 +80,89 @@ type TopicCreateRequest struct { RetentionMs int64 `json:"retentionMs,omitempty"` } -// fluvioProduce attempts to produce to Fluvio via HTTP API -// Falls back to logging when Fluvio is not running +func isProduction() bool { + return strings.EqualFold(os.Getenv("APP_ENV"), "production") || strings.EqualFold(os.Getenv("NODE_ENV"), "production") +} + +func validateFluvioConfiguration() error { + produceURL := strings.TrimRight(strings.TrimSpace(os.Getenv("FLUVIO_PRODUCE_URL")), "/") + if FLUVIO_ENDPOINT == "" && produceURL == "" { + return errors.New("FLUVIO_ENDPOINT or FLUVIO_PRODUCE_URL is required") + } + if isProduction() { + if produceURL == "" { + return errors.New("production Fluvio relay requires FLUVIO_PRODUCE_URL") + } + parsed, err := url.ParseRequestURI(produceURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return errors.New("production FLUVIO_PRODUCE_URL must be an absolute HTTPS endpoint") + } + if len(strings.TrimSpace(os.Getenv("FLUVIO_AUTH_TOKEN"))) < 32 { + return errors.New("production Fluvio relay requires a non-placeholder FLUVIO_AUTH_TOKEN of at least 32 characters") + } + } + return nil +} + +func isApprovedTopic(topic string) bool { + for _, approved := range NdsepTopics { + if topic == approved { + return true + } + } + return false +} + func fluvioProduce(topic, key string, payload interface{}) error { - body, _ := json.Marshal(payload) - // Fluvio HTTP API endpoint - url := fmt.Sprintf("http://%s/api/v1/produce/%s", FLUVIO_ENDPOINT, topic) - client := &http.Client{Timeout: 3 * time.Second} - req, _ := http.NewRequest("POST", url, &bytesReader{data: body}) + if !isApprovedTopic(topic) { + return fmt.Errorf("Fluvio topic is not allow-listed: %s", topic) + } + if isProduction() && !strings.EqualFold(os.Getenv("FLUVIO_ENABLED"), "true") { + return errors.New("Fluvio is disabled in production; set FLUVIO_ENABLED=true only after approved cluster validation") + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal Fluvio payload: %w", err) + } + endpoint := fmt.Sprintf("http://%s/api/v1/produce/%s", FLUVIO_ENDPOINT, topic) + if raw := os.Getenv("FLUVIO_PRODUCE_URL"); raw != "" { + endpoint = fmt.Sprintf("%s/%s", strings.TrimRight(raw, "/"), topic) + } + parsed, err := url.ParseRequestURI(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return errors.New("FLUVIO_PRODUCE_URL must be an absolute endpoint") + } + if isProduction() && parsed.Scheme != "https" { + return errors.New("production Fluvio endpoint must use HTTPS") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create Fluvio request: %w", err) + } req.Header.Set("Content-Type", "application/json") if key != "" { req.Header.Set("X-Fluvio-Key", key) } + if token := os.Getenv("FLUVIO_AUTH_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } else if isProduction() { + return errors.New("production Fluvio requires FLUVIO_AUTH_TOKEN") + } + client := &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { - // Fluvio not running — graceful degradation - atomic.AddInt64(&fallbackCount, 1) - log.Printf("[FluvioRelay] Fluvio degraded, event logged: topic=%s key=%s", topic, key) - return nil + return fmt.Errorf("Fluvio required delivery failed: %w", err) } defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("fluvio produce failed: status=%d", resp.StatusCode) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("Fluvio required delivery failed with HTTP %d", resp.StatusCode) } return nil } -type bytesReader struct { - data []byte - pos int -} - -func (r *bytesReader) Read(p []byte) (n int, err error) { - if r.pos >= len(r.data) { - return 0, fmt.Errorf("EOF") - } - n = copy(p, r.data[r.pos:]) - r.pos += n - return n, nil -} - -func (r *bytesReader) Close() error { return nil } - func handleProduce(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) @@ -133,8 +173,8 @@ func handleProduce(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - if req.Topic == "" { - http.Error(w, "topic required", http.StatusBadRequest) + if !isApprovedTopic(req.Topic) { + http.Error(w, "allow-listed topic required", http.StatusBadRequest) return } @@ -178,24 +218,11 @@ func handleTopicCreate(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - if req.Partitions == 0 { - req.Partitions = 3 - } - if req.ReplicationFactor == 0 { - req.ReplicationFactor = 1 - } - if req.RetentionMs == 0 { - req.RetentionMs = 604800000 // 7 days + if !isApprovedTopic(req.Name) { + http.Error(w, "allow-listed topic required", http.StatusBadRequest) + return } - atomic.AddInt64(&topicCount, 1) - log.Printf("[FluvioRelay] Topic create requested: name=%s partitions=%d", req.Name, req.Partitions) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "topic": req.Name, - "partitions": req.Partitions, - "retention": req.RetentionMs, - }) + http.Error(w, "topic provisioning is disabled; use the authenticated Fluvio control plane", http.StatusNotImplemented) } func handleHealth(w http.ResponseWriter, r *http.Request) { @@ -203,12 +230,10 @@ func handleHealth(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ "status": "healthy", "service": "ndsep-fluvio-relay", - "version": "1.0.0", + "version": "1.1.0", "uptime": time.Since(startTime).Seconds(), "config": map[string]string{ "fluvio_endpoint": FLUVIO_ENDPOINT, - "kafka_broker": KAFKA_BROKER, - "kafka_fallback": KAFKA_FALLBACK, }, "topics": len(NdsepTopics), }) @@ -219,9 +244,6 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "# HELP ndsep_fluvio_produce_total Total Fluvio produce calls\n") fmt.Fprintf(w, "# TYPE ndsep_fluvio_produce_total counter\n") fmt.Fprintf(w, "ndsep_fluvio_produce_total %d\n", atomic.LoadInt64(&produceCount)) - fmt.Fprintf(w, "# HELP ndsep_fluvio_fallback_total Kafka fallback activations\n") - fmt.Fprintf(w, "# TYPE ndsep_fluvio_fallback_total counter\n") - fmt.Fprintf(w, "ndsep_fluvio_fallback_total %d\n", atomic.LoadInt64(&fallbackCount)) fmt.Fprintf(w, "# HELP ndsep_fluvio_errors_total Total errors\n") fmt.Fprintf(w, "# TYPE ndsep_fluvio_errors_total counter\n") fmt.Fprintf(w, "ndsep_fluvio_errors_total %d\n", atomic.LoadInt64(&errorCount)) @@ -231,15 +253,19 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) { } func main() { + if err := validateFluvioConfiguration(); err != nil { + log.Fatal("[FluvioRelay] ", err) + } mux := http.NewServeMux() mux.HandleFunc("/produce", handleProduce) + mux.HandleFunc("/publish", handleProduce) mux.HandleFunc("/topics", handleTopics) mux.HandleFunc("/topics/create", handleTopicCreate) mux.HandleFunc("/health", handleHealth) mux.HandleFunc("/metrics", handleMetrics) log.Printf("[FluvioRelay] Starting NDSEP Fluvio Event Relay on port %s", PORT) - log.Printf("[FluvioRelay] Fluvio endpoint: %s | Kafka fallback: %s", FLUVIO_ENDPOINT, KAFKA_FALLBACK) + log.Printf("[FluvioRelay] Fluvio endpoint: %s", FLUVIO_ENDPOINT) log.Printf("[FluvioRelay] Managing %d NDSEP topics", len(NdsepTopics)) ctx, cancel := context.WithCancel(context.Background()) diff --git a/workers/go/cmd/fluvio_relay/main_test.go b/workers/go/cmd/fluvio_relay/main_test.go new file mode 100644 index 00000000..bafd3279 --- /dev/null +++ b/workers/go/cmd/fluvio_relay/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestFluvioProduceRejectsUnknownTopic(t *testing.T) { + t.Setenv("APP_ENV", "test") + if err := fluvioProduce("unapproved.topic", "key", map[string]string{"event_id": "evt-1"}); err == nil { + t.Fatal("unapproved topic was accepted") + } +} + +func TestFluvioProduceRejectsPlaintextProductionEndpoint(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("FLUVIO_ENABLED", "true") + t.Setenv("FLUVIO_AUTH_TOKEN", "test-token") + t.Setenv("FLUVIO_PRODUCE_URL", "http://fluvio.internal/api/v1/produce") + if err := fluvioProduce(NdsepTopics[0], "key", map[string]string{"event_id": "evt-2"}); err == nil { + t.Fatal("production relay accepted plaintext Fluvio endpoint") + } +} + +func TestHandleProduceDeliversPayloadToApprovedEndpoint(t *testing.T) { + received := make(chan map[string]interface{}, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if got := r.Header.Get("X-Fluvio-Key"); got != "event-3" { + t.Fatalf("Fluvio key = %q, want event-3", got) + } + var value map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&value); err != nil { + t.Fatalf("decode payload: %v", err) + } + received <- value + w.WriteHeader(http.StatusAccepted) + })) + defer upstream.Close() + + t.Setenv("APP_ENV", "test") + t.Setenv("FLUVIO_PRODUCE_URL", upstream.URL) + request := httptest.NewRequest(http.MethodPost, "/publish", strings.NewReader(`{"topic":"ndsep.compliance.events","key":"event-3","payload":{"event_id":"evt-3","severity":"high"}}`)) + response := httptest.NewRecorder() + handleProduce(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", response.Code, response.Body.String()) + } + select { + case payload := <-received: + if payload["event_id"] != "evt-3" || payload["severity"] != "high" { + t.Fatalf("upstream payload missing event data: %#v", payload) + } + if payload["_ndsep_topic"] != "ndsep.compliance.events" { + t.Fatalf("upstream payload missing NDSEP topic metadata: %#v", payload) + } + default: + t.Fatal("approved event was not delivered upstream") + } +} + +func TestTopicCreateIsDisabled(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/topics/create", strings.NewReader(`{"name":"ndsep.compliance.events"}`)) + response := httptest.NewRecorder() + handleTopicCreate(response, request) + if response.Code != http.StatusNotImplemented { + t.Fatalf("topic creation status = %d, want 501", response.Code) + } +} + +func TestValidateFluvioConfigurationFailsWithoutDestination(t *testing.T) { + originalEndpoint := FLUVIO_ENDPOINT + originalURL, hadURL := os.LookupEnv("FLUVIO_PRODUCE_URL") + defer func() { + FLUVIO_ENDPOINT = originalEndpoint + if hadURL { + _ = os.Setenv("FLUVIO_PRODUCE_URL", originalURL) + } else { + _ = os.Unsetenv("FLUVIO_PRODUCE_URL") + } + }() + FLUVIO_ENDPOINT = "" + _ = os.Unsetenv("FLUVIO_PRODUCE_URL") + if err := validateFluvioConfiguration(); err == nil { + t.Fatal("expected Fluvio relay without endpoint configuration to fail") + } +} + +func TestValidateFluvioConfigurationRequiresHTTPSAndTokenInProduction(t *testing.T) { + originalEndpoint := FLUVIO_ENDPOINT + originalNodeEnv, hadNodeEnv := os.LookupEnv("NODE_ENV") + originalURL, hadURL := os.LookupEnv("FLUVIO_PRODUCE_URL") + originalToken, hadToken := os.LookupEnv("FLUVIO_AUTH_TOKEN") + defer func() { + FLUVIO_ENDPOINT = originalEndpoint + if hadNodeEnv { + _ = os.Setenv("NODE_ENV", originalNodeEnv) + } else { + _ = os.Unsetenv("NODE_ENV") + } + if hadURL { + _ = os.Setenv("FLUVIO_PRODUCE_URL", originalURL) + } else { + _ = os.Unsetenv("FLUVIO_PRODUCE_URL") + } + if hadToken { + _ = os.Setenv("FLUVIO_AUTH_TOKEN", originalToken) + } else { + _ = os.Unsetenv("FLUVIO_AUTH_TOKEN") + } + }() + FLUVIO_ENDPOINT = "" + _ = os.Setenv("NODE_ENV", "production") + _ = os.Setenv("FLUVIO_PRODUCE_URL", "http://fluvio.internal/produce") + _ = os.Setenv("FLUVIO_AUTH_TOKEN", "short") + if err := validateFluvioConfiguration(); err == nil { + t.Fatal("expected production HTTP Fluvio endpoint to fail") + } + _ = os.Setenv("FLUVIO_PRODUCE_URL", "https://fluvio.internal/produce") + _ = os.Setenv("FLUVIO_AUTH_TOKEN", "0123456789abcdef0123456789abcdef") + if err := validateFluvioConfiguration(); err != nil { + t.Fatalf("expected complete production Fluvio configuration to validate: %v", err) + } +} diff --git a/workers/go/cmd/openappsec_waf/main.go b/workers/go/cmd/openappsec_waf/main.go index 35cb0634..91b10823 100644 --- a/workers/go/cmd/openappsec_waf/main.go +++ b/workers/go/cmd/openappsec_waf/main.go @@ -1,17 +1,34 @@ package main import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "log" "net/http" + "net/url" "os" + "strconv" + "strings" "sync" "time" ) -// OpenAppSec WAF Integration Worker -// Monitors and manages WAF rules, threat detection, and request filtering. +// OpenAppSec WAF Integration Worker monitors WAF rules, threat detection and request filtering. +// It sends authenticated operational events to the NDSEP worker-event boundary. Events are +// telemetry only: downstream services must independently validate policy and evidence before +// taking any enforcement action. +const ( + workerEventSignatureVersion = "ndsep-worker-event-v1" + workerID = "openappsec-waf" +) type WAFEvent struct { ID string `json:"id"` @@ -27,30 +44,41 @@ type WAFEvent struct { type WAFStats struct { TotalRequests int64 `json:"total_requests"` BlockedRequests int64 `json:"blocked_requests"` + RelayFailures int64 `json:"relay_failures"` ThreatsByType map[string]int `json:"threats_by_type"` TopAttackerIPs []string `json:"top_attacker_ips"` LastUpdated time.Time `json:"last_updated"` } var ( - stats = WAFStats{ - ThreatsByType: make(map[string]int), - } - mu sync.Mutex + stats = WAFStats{ThreatsByType: make(map[string]int)} + mu sync.Mutex ) -func healthHandler(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]string{ +func isProduction() bool { + return strings.EqualFold(os.Getenv("APP_ENV"), "production") || strings.EqualFold(os.Getenv("NODE_ENV"), "production") +} + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + degraded := isProduction() && (os.Getenv("WORKER_RELAY_URL") == "" || len(os.Getenv("WORKER_EVENT_HMAC_SECRET")) < 32) + mu.Unlock() + if degraded { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "degraded", "reason": "authenticated relay is not configured"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ "status": "healthy", "service": "openappsec-waf-worker", - "version": "1.0.0", + "version": "1.1.0", }) } -func statsHandler(w http.ResponseWriter, r *http.Request) { +func statsHandler(w http.ResponseWriter, _ *http.Request) { mu.Lock() defer mu.Unlock() - json.NewEncoder(w).Encode(stats) + _ = json.NewEncoder(w).Encode(stats) } func eventHandler(w http.ResponseWriter, r *http.Request) { @@ -58,11 +86,19 @@ func eventHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } + defer r.Body.Close() + var event WAFEvent - if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&event); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } + if event.ID == "" || event.Action == "" || event.Threat == "" || event.Severity == "" { + http.Error(w, "event id, action, threat and severity are required", http.StatusBadRequest) + return + } mu.Lock() stats.TotalRequests++ @@ -70,30 +106,107 @@ func eventHandler(w http.ResponseWriter, r *http.Request) { stats.BlockedRequests++ stats.ThreatsByType[event.Threat]++ } - stats.LastUpdated = time.Now() + stats.LastUpdated = time.Now().UTC() mu.Unlock() - // Relay to main API relayURL := os.Getenv("WORKER_RELAY_URL") - if relayURL != "" { - go relayEvent(relayURL, event) + secret := os.Getenv("WORKER_EVENT_HMAC_SECRET") + if relayURL == "" || len(secret) < 32 { + if isProduction() { + mu.Lock() + stats.RelayFailures++ + mu.Unlock() + http.Error(w, "authenticated event relay is not configured", http.StatusServiceUnavailable) + return + } + log.Printf("relay skipped outside production: authenticated relay is not configured event_id=%s", event.ID) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "received", "relay": "not_configured"}) + return } - json.NewEncoder(w).Encode(map[string]string{"status": "received"}) + go func() { + if err := relayEvent(relayURL, secret, event); err != nil { + mu.Lock() + stats.RelayFailures++ + mu.Unlock() + log.Printf("authenticated WAF event relay failed event_id=%s: %v", event.ID, err) + } + }() + _ = json.NewEncoder(w).Encode(map[string]string{"status": "received", "relay": "queued"}) } -func relayEvent(url string, event WAFEvent) { - payload, _ := json.Marshal(map[string]interface{}{ - "event": "waf_event", - "data": event, - }) - req, _ := http.NewRequest("POST", url, nil) +func createNonce() (string, error) { + value := make([]byte, 24) + if _, err := rand.Read(value); err != nil { + return "", fmt.Errorf("generate relay nonce: %w", err) + } + return hex.EncodeToString(value), nil +} + +func signWorkerEvent(secret, timestamp, nonce string, payload []byte) string { + bodyHash := sha256.Sum256(payload) + material := strings.Join([]string{workerEventSignatureVersion, workerID, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(material)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func validateRelayURL(rawURL string) (*url.URL, error) { + endpoint, err := url.ParseRequestURI(rawURL) + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New("WORKER_RELAY_URL must be an absolute URL") + } + if isProduction() && endpoint.Scheme != "https" { + return nil, errors.New("WORKER_RELAY_URL must use HTTPS in production") + } + return endpoint, nil +} + +func relayEvent(rawURL, secret string, event WAFEvent) error { + if len(secret) < 32 { + return errors.New("WORKER_EVENT_HMAC_SECRET must be at least 32 characters") + } + endpoint, err := validateRelayURL(rawURL) + if err != nil { + return err + } + payload, err := json.Marshal(map[string]interface{}{"event": "waf_event", "data": event}) + if err != nil { + return fmt.Errorf("marshal WAF event: %w", err) + } + nonce, err := createNonce() + if err != nil { + return err + } + timestamp := strconv.FormatInt(time.Now().UTC().UnixMilli(), 10) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("create relay request: %w", err) + } req.Header.Set("Content-Type", "application/json") - req.Body = http.NoBody - // Use the payload - _ = payload - client := &http.Client{Timeout: 5 * time.Second} - client.Do(req) + req.Header.Set("X-NDSEP-Worker-ID", workerID) + req.Header.Set("X-NDSEP-Event-Timestamp", timestamp) + req.Header.Set("X-NDSEP-Event-Nonce", nonce) + req.Header.Set("X-NDSEP-Event-Signature", signWorkerEvent(secret, timestamp, nonce, payload)) + + client := &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + response, err := client.Do(req) + if err != nil { + return fmt.Errorf("deliver signed relay event: %w", err) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("relay endpoint rejected event with HTTP %d", response.StatusCode) + } + return nil } func main() { @@ -108,16 +221,15 @@ func main() { mux.HandleFunc("/event", eventHandler) log.Printf("OpenAppSec WAF Worker starting on :%s", port) - server := &http.Server{ - Addr: fmt.Sprintf(":%s", port), - Handler: mux, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 120 * time.Second, + Addr: fmt.Sprintf(":%s", port), + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, } - - if err := server.ListenAndServe(); err != nil { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Server failed: %v", err) } } diff --git a/workers/go/cmd/openappsec_waf/main_test.go b/workers/go/cmd/openappsec_waf/main_test.go new file mode 100644 index 00000000..9d9b5354 --- /dev/null +++ b/workers/go/cmd/openappsec_waf/main_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestRelayEventDeliversExactSignedPayload(t *testing.T) { + const secret = "0123456789abcdef0123456789abcdef" + accepted := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("X-NDSEP-Worker-ID"); got != workerID { + t.Fatalf("worker ID = %q, want %q", got, workerID) + } + var payload map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode relay body: %v", err) + } + if string(payload["event"]) != `"waf_event"` { + t.Fatalf("event = %s, want waf_event", payload["event"]) + } + accepted <- struct{}{} + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + t.Setenv("APP_ENV", "test") + if err := relayEvent(server.URL, secret, WAFEvent{ + ID: "waf-test-1", Timestamp: time.Now().UTC(), SourceIP: "198.51.100.7", + Path: "/api/test", Action: "block", Threat: "sqli", Severity: "high", + }); err != nil { + t.Fatalf("relayEvent returned error: %v", err) + } + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("relay endpoint did not receive signed payload") + } +} + +func TestWorkerEventSignatureBindsExactPayload(t *testing.T) { + payload := []byte(`{"event":"waf_event","data":{"id":"one"}}`) + secret := "0123456789abcdef0123456789abcdef" + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + nonce := strings.Repeat("a", 48) + signature := signWorkerEvent(secret, timestamp, nonce, payload) + + bodyHash := sha256.Sum256(payload) + material := strings.Join([]string{workerEventSignatureVersion, workerID, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(material)) + if signature != hex.EncodeToString(mac.Sum(nil)) { + t.Fatal("signature does not match documented NDSEP worker-event protocol") + } + if signature == signWorkerEvent(secret, timestamp, nonce, []byte(`{"event":"waf_event","data":{"id":"two"}}`)) { + t.Fatal("signature must change when exact payload bytes change") + } +} + +func TestProductionRelayRejectsPlaintextEndpoint(t *testing.T) { + t.Setenv("APP_ENV", "production") + if _, err := validateRelayURL("http://worker-api.internal/api/workers/event"); err == nil { + t.Fatal("production relay accepted plaintext HTTP endpoint") + } + if _, err := validateRelayURL("https://worker-api.internal/api/workers/event"); err != nil { + t.Fatalf("production relay rejected HTTPS endpoint: %v", err) + } +} diff --git a/workers/go/cmd/webhook_delivery/main.go b/workers/go/cmd/webhook_delivery/main.go index ffa7ceda..05483e62 100644 --- a/workers/go/cmd/webhook_delivery/main.go +++ b/workers/go/cmd/webhook_delivery/main.go @@ -1,6 +1,7 @@ -// webhook_delivery — NDSEP Enhancement -// Reliable webhook delivery worker with exponential backoff, signature signing, -// and dead-letter queue. Delivers platform events to registered DPCO/org endpoints. +// webhook_delivery consumes migration-owned webhook_delivery_attempts rows in active mode. +// It never creates or alters database schema at runtime. Canonical outcomes remain +// append-only webhook_deliveries records; receiver delivery is at-least-once and uses +// the stable event ID header for downstream recipient idempotency. package main import ( @@ -11,241 +12,576 @@ import ( "database/sql" "encoding/hex" "encoding/json" + "errors" "fmt" "log" + "net" "net/http" + "net/netip" + "net/url" "os" "os/signal" + "strconv" + "strings" + "sync/atomic" "syscall" "time" + "github.com/google/uuid" _ "github.com/lib/pq" ) -var ( - dbURL = envOrDefault("NDSEP_PG_URL", "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db") - workerID = envOrDefault("WEBHOOK_WORKER_ID", "webhook-worker-1") - maxRetries = 5 - baseDelay = 30 * time.Second +const ( + queueModeActive = "active" + maxErrorLength = 1024 ) -func envOrDefault(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -// ─── Data Types ─────────────────────────────────────────────────────────────── - -type WebhookDelivery struct { - ID int64 `json:"id"` - WebhookID int64 `json:"webhook_id"` - EventType string `json:"event_type"` - Payload json.RawMessage `json:"payload"` - TargetURL string `json:"target_url"` - Secret string `json:"-"` - Attempts int `json:"attempts"` - Status string `json:"status"` - NextRetryAt time.Time `json:"next_retry_at"` - CreatedAt time.Time `json:"created_at"` -} - -// ─── Database ───────────────────────────────────────────────────────────────── - -func ensureSchema(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS webhook_deliveries ( - id SERIAL PRIMARY KEY, - webhook_id INTEGER NOT NULL, - event_type TEXT NOT NULL, - payload JSONB NOT NULL, - target_url TEXT NOT NULL, - secret TEXT, - attempts INTEGER DEFAULT 0, - status TEXT DEFAULT 'pending', -- pending, delivered, failed, dead - last_response_code INTEGER, - last_response_body TEXT, - next_retry_at TIMESTAMPTZ DEFAULT NOW(), - created_at TIMESTAMPTZ DEFAULT NOW(), - delivered_at TIMESTAMPTZ - ); - CREATE INDEX IF NOT EXISTS idx_wh_del_status ON webhook_deliveries(status, next_retry_at); - CREATE INDEX IF NOT EXISTS idx_wh_del_webhook ON webhook_deliveries(webhook_id); - `) - return err -} - -func fetchPendingDeliveries(db *sql.DB, limit int) ([]WebhookDelivery, error) { - rows, err := db.Query(` - SELECT id, webhook_id, event_type, payload, target_url, - COALESCE(secret, ''), attempts, status, next_retry_at, created_at - FROM webhook_deliveries - WHERE status IN ('pending', 'retrying') - AND next_retry_at <= NOW() - ORDER BY next_retry_at ASC +var errSubscriptionInactive = errors.New("subscription inactive before worker dispatch") + +type workerConfig struct { + databaseURL string + workerID string + batchSize int + pollInterval time.Duration + leaseDuration time.Duration + httpTimeout time.Duration + metricsAddress string +} + +type workerMetrics struct { + claimed atomic.Uint64 + delivered atomic.Uint64 + retried atomic.Uint64 + dead atomic.Uint64 + claimErrors atomic.Uint64 + finalizeErrors atomic.Uint64 + leaseRecoveries atomic.Uint64 + lastClaimUnixSec atomic.Int64 +} + +type webhookDeliveryAttempt struct { + ID int64 + SubscriptionID int + EventID uuid.UUID + EventType string + Payload json.RawMessage + TargetURL string + Secret string + SubscriptionActive bool + AttemptCount int + MaxAttempts int + ClaimToken uuid.UUID +} + +func requiredEnv(key string) (string, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return "", fmt.Errorf("%s is required", key) + } + return value, nil +} + +func boundedIntEnv(key string, fallback, min, max int) (int, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < min || parsed > max { + return 0, fmt.Errorf("%s must be an integer from %d to %d", key, min, max) + } + return parsed, nil +} + +func boundedDurationEnv(key string, fallback time.Duration, min, max time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed < min || parsed > max { + return 0, fmt.Errorf("%s must be a duration from %s to %s", key, min, max) + } + return parsed, nil +} + +func loadConfig() (workerConfig, error) { + if strings.TrimSpace(os.Getenv("WEBHOOK_DELIVERY_QUEUE_MODE")) != queueModeActive { + return workerConfig{}, errors.New("webhook delivery worker requires WEBHOOK_DELIVERY_QUEUE_MODE=active") + } + databaseURL, err := requiredEnv("NDSEP_PG_URL") + if err != nil { + return workerConfig{}, err + } + workerID, err := requiredEnv("WEBHOOK_WORKER_ID") + if err != nil { + return workerConfig{}, err + } + batchSize, err := boundedIntEnv("WEBHOOK_WORKER_BATCH_SIZE", 50, 1, 500) + if err != nil { + return workerConfig{}, err + } + pollInterval, err := boundedDurationEnv("WEBHOOK_WORKER_POLL_INTERVAL", 5*time.Second, time.Second, time.Minute) + if err != nil { + return workerConfig{}, err + } + leaseDuration, err := boundedDurationEnv("WEBHOOK_WORKER_LEASE_DURATION", 90*time.Second, 30*time.Second, 10*time.Minute) + if err != nil { + return workerConfig{}, err + } + httpTimeout, err := boundedDurationEnv("WEBHOOK_WORKER_HTTP_TIMEOUT", 10*time.Second, time.Second, 60*time.Second) + if err != nil { + return workerConfig{}, err + } + metricsAddress := strings.TrimSpace(os.Getenv("WEBHOOK_WORKER_METRICS_ADDRESS")) + if metricsAddress == "" { + metricsAddress = ":8094" + } + return workerConfig{databaseURL, workerID, batchSize, pollInterval, leaseDuration, httpTimeout, metricsAddress}, nil +} + +func verifySchema(ctx context.Context, db *sql.DB) error { + const schemaCheck = ` + SELECT + to_regclass('public.webhook_delivery_attempts') IS NOT NULL + AND to_regclass('public.webhook_deliveries') IS NOT NULL + AND EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'webhook_delivery_attempts' + AND column_name IN ('claim_token', 'claim_owner', 'claim_expires_at', 'last_error') + GROUP BY table_schema, table_name HAVING count(*) = 4 + ) + AND EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'webhook_deliveries' + AND indexname = 'uq_webhook_deliveries_queue_attempt' + ) + AND EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'webhook_deliveries'::regclass + AND conname = 'webhook_deliveries_queue_attempt_id_fk' + ) + AND ( + SELECT count(*) FROM pg_constraint + WHERE conrelid = 'webhook_delivery_attempts'::regclass + AND conname IN ( + 'webhook_delivery_attempt_processing_claim_check', + 'webhook_delivery_attempt_terminal_claim_clear_check', + 'webhook_delivery_attempt_processing_lease_window_check' + ) + ) = 3` + var ready bool + if err := db.QueryRowContext(ctx, schemaCheck).Scan(&ready); err != nil { + return fmt.Errorf("verify webhook queue schema: %w", err) + } + if !ready { + return errors.New("migration-owned webhook queue schema is incomplete; apply root migrations through 0047 before starting the worker") + } + return nil +} + +func recoverExpiredLeases(ctx context.Context, db *sql.DB, metrics *workerMetrics) error { + result, err := db.ExecContext(ctx, ` + UPDATE webhook_delivery_attempts + SET status = 'retrying', + next_retry_at = now(), + claim_token = NULL, + claim_owner = NULL, + claim_expires_at = NULL, + updated_at = now(), + last_error = COALESCE(last_error, 'worker lease expired before finalization') + WHERE status = 'processing' + AND claim_expires_at <= now()`) + if err != nil { + return fmt.Errorf("recover expired webhook leases: %w", err) + } + if count, err := result.RowsAffected(); err == nil && count > 0 { + metrics.leaseRecoveries.Add(uint64(count)) + } + return nil +} + +func claimEligibleAttempts(ctx context.Context, db *sql.DB, cfg workerConfig, metrics *workerMetrics) ([]webhookDeliveryAttempt, error) { + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return nil, fmt.Errorf("begin webhook claim transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + UPDATE webhook_delivery_attempts + SET status = 'retrying', + next_retry_at = now(), + claim_token = NULL, + claim_owner = NULL, + claim_expires_at = NULL, + updated_at = now(), + last_error = COALESCE(last_error, 'worker lease expired before finalization') + WHERE status = 'processing' + AND claim_expires_at <= now()`); err != nil { + return nil, fmt.Errorf("recover expired webhook leases in claim transaction: %w", err) + } + + rows, err := tx.QueryContext(ctx, ` + SELECT a.id, a.subscription_id, a.event_id, a.event_type, a.payload, + a.destination_url, s.secret, s.active, a.attempt_count, a.max_attempts + FROM webhook_delivery_attempts AS a + JOIN webhook_subscriptions AS s ON s.id = a.subscription_id + WHERE a.status IN ('pending', 'retrying') + AND a.next_retry_at <= now() + ORDER BY a.next_retry_at ASC, a.id ASC LIMIT $1 - FOR UPDATE SKIP LOCKED - `, limit) + FOR UPDATE OF a SKIP LOCKED`, cfg.batchSize) if err != nil { - return nil, err + return nil, fmt.Errorf("select claimable webhook attempts: %w", err) } defer rows.Close() - var deliveries []WebhookDelivery + claimed := make([]webhookDeliveryAttempt, 0, cfg.batchSize) for rows.Next() { - var d WebhookDelivery - if err := rows.Scan( - &d.ID, &d.WebhookID, &d.EventType, &d.Payload, - &d.TargetURL, &d.Secret, &d.Attempts, &d.Status, - &d.NextRetryAt, &d.CreatedAt, - ); err != nil { - continue + var attempt webhookDeliveryAttempt + if err := rows.Scan(&attempt.ID, &attempt.SubscriptionID, &attempt.EventID, &attempt.EventType, &attempt.Payload, &attempt.TargetURL, &attempt.Secret, &attempt.SubscriptionActive, &attempt.AttemptCount, &attempt.MaxAttempts); err != nil { + return nil, fmt.Errorf("scan claimable webhook attempt: %w", err) } - deliveries = append(deliveries, d) + attempt.ClaimToken = uuid.New() + claimed = append(claimed, attempt) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate claimable webhook attempts: %w", err) } - return deliveries, nil + // PostgreSQL locks are held by this transaction, but lib/pq requires the + // result cursor to be closed before issuing the tokenized claim updates. + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close claimable webhook cursor: %w", err) + } + for index := range claimed { + attempt := &claimed[index] + result, err := tx.ExecContext(ctx, ` + UPDATE webhook_delivery_attempts + SET status = 'processing', + attempt_count = attempt_count + 1, + claimed_at = now(), + claim_token = $2::uuid, + claim_owner = $3, + claim_expires_at = now() + $4::interval, + updated_at = now(), + last_error = NULL + WHERE id = $1 + AND status IN ('pending', 'retrying')`, attempt.ID, attempt.ClaimToken.String(), cfg.workerID, fmt.Sprintf("%d milliseconds", cfg.leaseDuration.Milliseconds())) + if err != nil { + return nil, fmt.Errorf("mark webhook attempt %d processing: %w", attempt.ID, err) + } + updated, err := result.RowsAffected() + if err != nil || updated != 1 { + return nil, fmt.Errorf("claim webhook attempt %d affected %d rows", attempt.ID, updated) + } + attempt.AttemptCount++ + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit webhook claim transaction: %w", err) + } + if len(claimed) > 0 { + metrics.claimed.Add(uint64(len(claimed))) + metrics.lastClaimUnixSec.Store(time.Now().UTC().Unix()) + } + return claimed, nil } -func markDelivered(db *sql.DB, id int64, statusCode int, body string) error { - _, err := db.Exec(` - UPDATE webhook_deliveries - SET status = 'delivered', delivered_at = NOW(), - last_response_code = $2, last_response_body = $3, - attempts = attempts + 1 - WHERE id = $1 - `, id, statusCode, body) - return err +func signPayload(payload []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } -func markFailed(db *sql.DB, id int64, attempts int, statusCode int, body string) error { - if attempts >= maxRetries { - _, err := db.Exec(` - UPDATE webhook_deliveries - SET status = 'dead', last_response_code = $2, last_response_body = $3, - attempts = attempts + 1 - WHERE id = $1 - `, id, statusCode, body) - return err +func deliver(ctx context.Context, client *http.Client, attempt webhookDeliveryAttempt, allowLocalTestTarget bool) (int, string, error) { + target, err := validateWebhookTarget(attempt.TargetURL, allowLocalTestTarget) + if err != nil { + return 0, boundedError(err.Error()), err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(attempt.Payload)) + if err != nil { + return 0, "", fmt.Errorf("construct webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-NDSEP-Event", attempt.EventType) + req.Header.Set("X-NDSEP-Delivery", attempt.EventID.String()) + req.Header.Set("X-NDSEP-Timestamp", strconv.FormatInt(time.Now().UTC().Unix(), 10)) + if attempt.Secret != "" { + req.Header.Set("X-NDSEP-Signature", signPayload(attempt.Payload, attempt.Secret)) } - // Exponential backoff: 30s, 2m, 8m, 32m, 2h - delay := baseDelay * time.Duration(1< 5 { + attemptCount = 5 + } + return 30 * time.Second * time.Duration(1<= 200 && statusCode < 300 + terminal := success || errors.Is(deliveryErr, errSubscriptionInactive) || attempt.AttemptCount >= attempt.MaxAttempts + status := "retrying" + if success { + status = "delivered" + } else if terminal { + status = "dead" } - resp, err := client.Do(req) + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { - return 0, err.Error(), err + return fmt.Errorf("begin webhook finalization transaction: %w", err) } - defer resp.Body.Close() + defer func() { _ = tx.Rollback() }() - bodyBuf := make([]byte, 512) - n, _ := resp.Body.Read(bodyBuf) - body := string(bodyBuf[:n]) - - return resp.StatusCode, body, nil + var lastError any + if deliveryErr != nil { + lastError = boundedError(deliveryErr.Error()) + } + var nextRetry any + if status == "retrying" { + nextRetry = time.Now().UTC().Add(retryDelay(attempt.AttemptCount)) + } + // Advance the queue row under its unique lease token before recording a + // canonical outcome. A stale worker therefore cannot consume the unique + // queue_attempt_id ledger link, and retryable outcomes do not become final. + result, err := tx.ExecContext(ctx, ` + UPDATE webhook_delivery_attempts + SET status = $3::varchar, + next_retry_at = COALESCE($4::timestamptz, next_retry_at), + claim_token = NULL, + claim_owner = NULL, + claim_expires_at = NULL, + delivered_at = CASE WHEN $3::varchar = 'delivered' THEN now() ELSE delivered_at END, + last_response_code = $5, + last_error = $6, + updated_at = now() + WHERE id = $1 + AND status = 'processing' + AND claim_token = $2::uuid`, attempt.ID, attempt.ClaimToken.String(), status, nextRetry, nullableResponseCode(statusCode), lastError) + if err != nil { + return fmt.Errorf("finalize webhook attempt %d: %w", attempt.ID, err) + } + updated, err := result.RowsAffected() + if err != nil || updated != 1 { + return fmt.Errorf("finalize webhook attempt %d affected %d rows; lease was lost or terminal state already recorded", attempt.ID, updated) + } + if terminal { + if err := recordCanonicalOutcome(ctx, tx, attempt, statusCode, success); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit webhook finalization transaction: %w", err) + } + if status == "delivered" { + metrics.delivered.Add(1) + } else if status == "dead" { + metrics.dead.Add(1) + } else { + metrics.retried.Add(1) + } + return nil } -// ─── Worker Loop ────────────────────────────────────────────────────────────── - -func runWorker(ctx context.Context, db *sql.DB) { - ticker := time.NewTicker(5 * time.Second) +func runWorker(ctx context.Context, db *sql.DB, cfg workerConfig, metrics *workerMetrics) { + client := newWebhookHTTPClient(cfg.httpTimeout) + ticker := time.NewTicker(cfg.pollInterval) defer ticker.Stop() - - log.Printf("[webhook_delivery] Worker %s started", workerID) + log.Printf("[webhook_delivery] worker=%s mode=active batch=%d lease=%s", cfg.workerID, cfg.batchSize, cfg.leaseDuration) for { select { case <-ctx.Done(): - log.Println("[webhook_delivery] Shutting down") + log.Printf("[webhook_delivery] worker=%s stopping; no new attempts will be claimed", cfg.workerID) return case <-ticker.C: - tx, err := db.BeginTx(ctx, nil) - if err != nil { - log.Printf("Begin tx: %v", err) - continue - } - - deliveries, err := fetchPendingDeliveries(db, 50) + attempts, err := claimEligibleAttempts(ctx, db, cfg, metrics) if err != nil { - tx.Rollback() - log.Printf("Fetch deliveries: %v", err) + metrics.claimErrors.Add(1) + log.Printf("[webhook_delivery] claim failure: %v", err) continue } - tx.Commit() - - for _, d := range deliveries { - statusCode, body, err := deliver(d) - if err != nil || statusCode < 200 || statusCode >= 300 { - if err != nil { - log.Printf("[FAIL] Delivery %d → %s: %v", d.ID, d.TargetURL, err) - } else { - log.Printf("[FAIL] Delivery %d → %s: HTTP %d", d.ID, d.TargetURL, statusCode) - } - if dbErr := markFailed(db, d.ID, d.Attempts+1, statusCode, body); dbErr != nil { - log.Printf("Mark failed: %v", dbErr) - } - } else { - log.Printf("[OK] Delivery %d → %s: HTTP %d", d.ID, d.TargetURL, statusCode) - if dbErr := markDelivered(db, d.ID, statusCode, body); dbErr != nil { - log.Printf("Mark delivered: %v", dbErr) + for _, attempt := range attempts { + if !attempt.SubscriptionActive { + if err := finalizeAttempt(ctx, db, attempt, 0, errSubscriptionInactive, metrics); err != nil { + metrics.finalizeErrors.Add(1) + log.Printf("[webhook_delivery] inactive subscription finalization failure attempt=%d: %v", attempt.ID, err) } + continue + } + statusCode, _, deliveryErr := deliver(ctx, client, attempt, false) + if err := finalizeAttempt(ctx, db, attempt, statusCode, deliveryErr, metrics); err != nil { + metrics.finalizeErrors.Add(1) + log.Printf("[webhook_delivery] finalization failure attempt=%d: %v", attempt.ID, err) } } } } } -// ─── Main ───────────────────────────────────────────────────────────────────── +func metricsHandler(metrics *workerMetrics) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_claimed_total counter\nndsep_webhook_queue_worker_claimed_total %d\n", metrics.claimed.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_delivered_total counter\nndsep_webhook_queue_worker_delivered_total %d\n", metrics.delivered.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_retried_total counter\nndsep_webhook_queue_worker_retried_total %d\n", metrics.retried.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_dead_total counter\nndsep_webhook_queue_worker_dead_total %d\n", metrics.dead.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_claim_errors_total counter\nndsep_webhook_queue_worker_claim_errors_total %d\n", metrics.claimErrors.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_finalization_errors_total counter\nndsep_webhook_queue_worker_finalization_errors_total %d\n", metrics.finalizeErrors.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_lease_recoveries_total counter\nndsep_webhook_queue_worker_lease_recoveries_total %d\n", metrics.leaseRecoveries.Load()) + _, _ = fmt.Fprintf(w, "# TYPE ndsep_webhook_queue_worker_last_claim_unixtime gauge\nndsep_webhook_queue_worker_last_claim_unixtime %d\n", metrics.lastClaimUnixSec.Load()) + } +} func main() { - db, err := sql.Open("postgres", dbURL) + cfg, err := loadConfig() if err != nil { - log.Fatalf("DB open: %v", err) + log.Fatalf("[webhook_delivery] configuration: %v", err) } - defer db.Close() - - if err := ensureSchema(db); err != nil { - log.Fatalf("Schema: %v", err) + db, err := sql.Open("postgres", cfg.databaseURL) + if err != nil { + log.Fatalf("[webhook_delivery] database open: %v", err) } - + defer db.Close() ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + if err := db.PingContext(ctx); err != nil { + log.Fatalf("[webhook_delivery] database ping: %v", err) + } + if err := verifySchema(ctx, db); err != nil { + log.Fatalf("[webhook_delivery] schema readiness: %v", err) + } + + metrics := &workerMetrics{} + server := &http.Server{Addr: cfg.metricsAddress, ReadHeaderTimeout: 5 * time.Second} + http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + http.HandleFunc("/metrics", metricsHandler(metrics)) + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("[webhook_delivery] metrics server: %v", err) + } + }() - runWorker(ctx, db) + runWorker(ctx, db, cfg, metrics) + shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("[webhook_delivery] metrics shutdown: %v", err) + } } diff --git a/workers/go/cmd/webhook_delivery/main_integration_test.go b/workers/go/cmd/webhook_delivery/main_integration_test.go new file mode 100644 index 00000000..56ca7d99 --- /dev/null +++ b/workers/go/cmd/webhook_delivery/main_integration_test.go @@ -0,0 +1,368 @@ +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + _ "github.com/lib/pq" +) + +func integrationDatabase(t *testing.T) *sql.DB { + t.Helper() + rawURL := os.Getenv("WEBHOOK_QUEUE_TEST_DATABASE_URL") + if rawURL == "" { + t.Skip("WEBHOOK_QUEUE_TEST_DATABASE_URL is required for real PostgreSQL worker integration tests") + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Hostname() != "127.0.0.1" && parsed.Hostname() != "localhost" { + t.Fatalf("WEBHOOK_QUEUE_TEST_DATABASE_URL must target localhost: %q", rawURL) + } + if !strings.Contains(parsed.Path, "ndsep_test") && !strings.Contains(parsed.Path, "ndsep_e2e") { + t.Fatalf("WEBHOOK_QUEUE_TEST_DATABASE_URL must name a disposable ndsep_test or ndsep_e2e database: %q", parsed.Path) + } + db, err := sql.Open("postgres", rawURL) + if err != nil { + t.Fatalf("open disposable PostgreSQL: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("ping disposable PostgreSQL: %v", err) + } + return db +} + +func resetWorkerFixtures(t *testing.T, db *sql.DB) { + t.Helper() + if _, err := db.Exec(`TRUNCATE TABLE webhook_delivery_attempts, webhook_deliveries, webhook_subscriptions RESTART IDENTITY CASCADE`); err != nil { + t.Fatalf("reset migration-owned webhook fixtures: %v", err) + } +} + +func attemptIdempotencyKey(subscriptionID int, eventID uuid.UUID) string { + digest := sha256.Sum256([]byte(strconv.Itoa(subscriptionID) + ":" + eventID.String())) + return hex.EncodeToString(digest[:]) +} + +func insertAttempt(t *testing.T, db *sql.DB, status string) (int64, uuid.UUID) { + t.Helper() + var subscriptionID int + if err := db.QueryRow(` + INSERT INTO webhook_subscriptions (org_id, url, events, secret, active) + VALUES (1, 'http://receiver.invalid/webhooks', ARRAY['audit.completed'], 'test-secret', true) + RETURNING id`).Scan(&subscriptionID); err != nil { + t.Fatalf("insert subscription fixture: %v", err) + } + eventID := uuid.New() + payload, err := json.Marshal(map[string]any{"id": eventID.String(), "kind": "integration-test"}) + if err != nil { + t.Fatalf("marshal event fixture: %v", err) + } + var id int64 + if err := db.QueryRow(` + INSERT INTO webhook_delivery_attempts + (subscription_id, event_id, event_type, payload, destination_url, status, idempotency_key) + VALUES ($1, $2, 'audit.completed', $3::jsonb, 'http://receiver.invalid/webhooks', $4, $5) + RETURNING id`, subscriptionID, eventID, string(payload), status, attemptIdempotencyKey(subscriptionID, eventID)).Scan(&id); err != nil { + t.Fatalf("insert queue fixture: %v", err) + } + return id, eventID +} + +func testConfig() workerConfig { + return workerConfig{ + workerID: "worker-integration-test", + batchSize: 10, + leaseDuration: time.Minute, + } +} + +func TestWorkerClaimsAndFinalizesExactlyOneCanonicalOutcomePostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + attemptID, eventID := insertAttempt(t, db, "pending") + metrics := &workerMetrics{} + attempts, err := claimEligibleAttempts(context.Background(), db, testConfig(), metrics) + if err != nil { + t.Fatalf("claim pending attempt: %v", err) + } + if len(attempts) != 1 || attempts[0].ID != attemptID || attempts[0].EventID != eventID || attempts[0].ClaimToken == uuid.Nil { + t.Fatalf("unexpected claimed attempt: %#v", attempts) + } + var status, owner string + var claimToken uuid.UUID + var expiresAt time.Time + if err := db.QueryRow(`SELECT status, claim_owner, claim_token, claim_expires_at FROM webhook_delivery_attempts WHERE id = $1`, attemptID).Scan(&status, &owner, &claimToken, &expiresAt); err != nil { + t.Fatalf("inspect claimed attempt: %v", err) + } + if status != "processing" || owner != "worker-integration-test" || claimToken != attempts[0].ClaimToken || !expiresAt.After(time.Now().UTC()) { + t.Fatalf("claim lease was not durably recorded: status=%q owner=%q token=%s expiry=%s", status, owner, claimToken, expiresAt) + } + + // Explicit local receiver test double; database persistence is real PostgreSQL. + receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-NDSEP-Delivery"); got != eventID.String() { + t.Errorf("stable receiver id=%q want=%q", got, eventID.String()) + } + w.WriteHeader(http.StatusNoContent) + })) + defer receiver.Close() + attempts[0].TargetURL = receiver.URL + statusCode, _, deliveryErr := deliver(context.Background(), receiver.Client(), attempts[0], true) + if deliveryErr != nil || statusCode != http.StatusNoContent { + t.Fatalf("deliver to local receiver: status=%d err=%v", statusCode, deliveryErr) + } + if err := finalizeAttempt(context.Background(), db, attempts[0], statusCode, nil, metrics); err != nil { + t.Fatalf("finalize successful attempt: %v", err) + } + + var finalStatus string + var clearedToken, clearedOwner, clearedExpiry any + if err := db.QueryRow(`SELECT status, claim_token, claim_owner, claim_expires_at FROM webhook_delivery_attempts WHERE id = $1`, attemptID).Scan(&finalStatus, &clearedToken, &clearedOwner, &clearedExpiry); err != nil { + t.Fatalf("inspect finalized attempt: %v", err) + } + if finalStatus != "delivered" || clearedToken != nil || clearedOwner != nil || clearedExpiry != nil { + t.Fatalf("final transition is not terminal/claim-cleared: status=%q token=%v owner=%v expiry=%v", finalStatus, clearedToken, clearedOwner, clearedExpiry) + } + var canonicalCount int + if err := db.QueryRow(`SELECT count(*) FROM webhook_deliveries WHERE queue_attempt_id = $1 AND success = true`, attemptID).Scan(&canonicalCount); err != nil { + t.Fatalf("inspect canonical ledger: %v", err) + } + if canonicalCount != 1 || metrics.delivered.Load() != 1 { + t.Fatalf("canonical outcome is not exactly once: rows=%d delivered=%d", canonicalCount, metrics.delivered.Load()) + } + if err := finalizeAttempt(context.Background(), db, attempts[0], statusCode, nil, metrics); err == nil { + t.Fatal("stale claim token finalization unexpectedly succeeded") + } + if err := db.QueryRow(`SELECT count(*) FROM webhook_deliveries WHERE queue_attempt_id = $1`, attemptID).Scan(&canonicalCount); err != nil || canonicalCount != 1 { + t.Fatalf("stale finalization duplicated canonical ledger: rows=%d err=%v", canonicalCount, err) + } +} + +func TestWorkerClaimSkipsAlreadyLeasedAttemptPostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + _, _ = insertAttempt(t, db, "pending") + metrics := &workerMetrics{} + first, err := claimEligibleAttempts(context.Background(), db, testConfig(), metrics) + if err != nil || len(first) != 1 { + t.Fatalf("first worker claim: rows=%d err=%v", len(first), err) + } + second, err := claimEligibleAttempts(context.Background(), db, workerConfig{workerID: "second-worker", batchSize: 10, leaseDuration: time.Minute}, metrics) + if err != nil { + t.Fatalf("second worker claim: %v", err) + } + if len(second) != 0 { + t.Fatalf("leased row was claimed twice: %#v", second) + } +} + +func TestWorkerRecoversExpiredLeaseAndReclaimsPostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + attemptID, _ := insertAttempt(t, db, "pending") + if _, err := db.Exec(` + UPDATE webhook_delivery_attempts + SET status = 'processing', claimed_at = now() - interval '2 minutes', + claim_token = $2::uuid, claim_owner = 'crashed-worker', + claim_expires_at = now() - interval '1 minute' + WHERE id = $1`, attemptID, uuid.New().String()); err != nil { + t.Fatalf("create expired lease fixture: %v", err) + } + metrics := &workerMetrics{} + if err := recoverExpiredLeases(context.Background(), db, metrics); err != nil { + t.Fatalf("recover expired lease: %v", err) + } + if metrics.leaseRecoveries.Load() != 1 { + t.Fatalf("expected one lease recovery, got %d", metrics.leaseRecoveries.Load()) + } + claimed, err := claimEligibleAttempts(context.Background(), db, testConfig(), metrics) + if err != nil || len(claimed) != 1 || claimed[0].ID != attemptID { + t.Fatalf("reclaim expired attempt: %#v err=%v", claimed, err) + } +} + +func TestWorkerConcurrentClaimsAreMutuallyExclusivePostgres(t *testing.T) { + dbFirst := integrationDatabase(t) + dbSecond := integrationDatabase(t) + resetWorkerFixtures(t, dbFirst) + attemptID, _ := insertAttempt(t, dbFirst, "pending") + + type claimResult struct { + attempts []webhookDeliveryAttempt + err error + } + start := make(chan struct{}) + results := make(chan claimResult, 2) + claim := func(db *sql.DB, workerID string) { + <-start + attempts, err := claimEligibleAttempts(context.Background(), db, workerConfig{ + workerID: workerID, + batchSize: 10, + leaseDuration: time.Minute, + }, &workerMetrics{}) + results <- claimResult{attempts: attempts, err: err} + } + go claim(dbFirst, "concurrent-worker-one") + go claim(dbSecond, "concurrent-worker-two") + close(start) + + first := <-results + second := <-results + if first.err != nil || second.err != nil { + t.Fatalf("concurrent claim failure: first=%v second=%v", first.err, second.err) + } + claimed := append(first.attempts, second.attempts...) + if len(claimed) != 1 || claimed[0].ID != attemptID || claimed[0].ClaimToken == uuid.Nil { + t.Fatalf("two PostgreSQL connections did not mutually exclude claim: %#v", claimed) + } + var status string + var owner string + if err := dbFirst.QueryRow(`SELECT status, claim_owner FROM webhook_delivery_attempts WHERE id = $1`, attemptID).Scan(&status, &owner); err != nil { + t.Fatalf("inspect concurrent claim: %v", err) + } + if status != "processing" || (owner != "concurrent-worker-one" && owner != "concurrent-worker-two") { + t.Fatalf("unexpected durable concurrent claim state: status=%q owner=%q", status, owner) + } +} + +func TestWorkerRecordsCanonicalOutcomeOnlyAfterTerminalStatePostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + attemptID, _ := insertAttempt(t, db, "pending") + metrics := &workerMetrics{} + claimed, err := claimEligibleAttempts(context.Background(), db, testConfig(), metrics) + if err != nil || len(claimed) != 1 { + t.Fatalf("claim retry fixture: %#v err=%v", claimed, err) + } + if err := finalizeAttempt(context.Background(), db, claimed[0], http.StatusBadGateway, nil, metrics); err != nil { + t.Fatalf("finalize retryable result: %v", err) + } + var status string + var canonicalCount int + if err := db.QueryRow(`SELECT status FROM webhook_delivery_attempts WHERE id = $1`, attemptID).Scan(&status); err != nil { + t.Fatalf("inspect retrying queue state: %v", err) + } + if err := db.QueryRow(`SELECT count(*) FROM webhook_deliveries WHERE queue_attempt_id = $1`, attemptID).Scan(&canonicalCount); err != nil { + t.Fatalf("inspect retryable canonical outcome: %v", err) + } + if status != "retrying" || canonicalCount != 0 || metrics.retried.Load() != 1 { + t.Fatalf("retryable failure became a canonical final outcome: status=%q rows=%d retried=%d", status, canonicalCount, metrics.retried.Load()) + } + if _, err := db.Exec(`UPDATE webhook_delivery_attempts SET next_retry_at = now() WHERE id = $1`, attemptID); err != nil { + t.Fatalf("make retry claimable: %v", err) + } + claimed, err = claimEligibleAttempts(context.Background(), db, testConfig(), metrics) + if err != nil || len(claimed) != 1 || claimed[0].ID != attemptID { + t.Fatalf("claim retried attempt: %#v err=%v", claimed, err) + } + if err := finalizeAttempt(context.Background(), db, claimed[0], http.StatusNoContent, nil, metrics); err != nil { + t.Fatalf("finalize eventual success: %v", err) + } + if err := db.QueryRow(`SELECT count(*) FROM webhook_deliveries WHERE queue_attempt_id = $1 AND success = true`, attemptID).Scan(&canonicalCount); err != nil { + t.Fatalf("inspect final canonical outcome: %v", err) + } + if canonicalCount != 1 || metrics.delivered.Load() != 1 { + t.Fatalf("terminal success did not create exactly one canonical outcome: rows=%d delivered=%d", canonicalCount, metrics.delivered.Load()) + } +} + +func TestWorkerCanonicalLedgerFailureRollsBackQueueFinalizationPostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + attemptID, _ := insertAttempt(t, db, "pending") + claimed, err := claimEligibleAttempts(context.Background(), db, testConfig(), &workerMetrics{}) + if err != nil || len(claimed) != 1 { + t.Fatalf("claim ledger failure fixture: %#v err=%v", claimed, err) + } + if _, err := db.Exec(`ALTER TABLE webhook_deliveries ADD CONSTRAINT webhook_deliveries_force_test_failure CHECK (false) NOT VALID`); err != nil { + t.Fatalf("install local ledger failure constraint: %v", err) + } + t.Cleanup(func() { + if _, err := db.Exec(`ALTER TABLE webhook_deliveries DROP CONSTRAINT IF EXISTS webhook_deliveries_force_test_failure`); err != nil { + t.Errorf("remove local ledger failure constraint: %v", err) + } + }) + if err := finalizeAttempt(context.Background(), db, claimed[0], http.StatusNoContent, nil, &workerMetrics{}); err == nil { + t.Fatal("canonical ledger failure unexpectedly finalized queue attempt") + } + var status string + var token uuid.UUID + if err := db.QueryRow(`SELECT status, claim_token FROM webhook_delivery_attempts WHERE id = $1`, attemptID).Scan(&status, &token); err != nil { + t.Fatalf("inspect queue after ledger rollback: %v", err) + } + if status != "processing" || token != claimed[0].ClaimToken { + t.Fatalf("canonical ledger failure partially finalized queue: status=%q token=%s", status, token) + } + var canonicalCount int + if err := db.QueryRow(`SELECT count(*) FROM webhook_deliveries WHERE queue_attempt_id = $1`, attemptID).Scan(&canonicalCount); err != nil { + t.Fatalf("inspect failed canonical ledger write: %v", err) + } + if canonicalCount != 0 { + t.Fatalf("failed canonical ledger write unexpectedly persisted rows=%d", canonicalCount) + } +} + +func TestWorkerFinalizesInactiveSubscriptionWithoutDeliveryPostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + attemptID, _ := insertAttempt(t, db, "pending") + if _, err := db.Exec(`UPDATE webhook_subscriptions SET active = false WHERE id = 1`); err != nil { + t.Fatalf("disable subscription after queue admission: %v", err) + } + metrics := &workerMetrics{} + claimed, err := claimEligibleAttempts(context.Background(), db, testConfig(), metrics) + if err != nil || len(claimed) != 1 || claimed[0].ID != attemptID { + t.Fatalf("claim inactive-subscription queue attempt: %#v err=%v", claimed, err) + } + if claimed[0].SubscriptionActive { + t.Fatal("claim did not preserve disabled subscription state") + } + if err := finalizeAttempt(context.Background(), db, claimed[0], 0, errSubscriptionInactive, metrics); err != nil { + t.Fatalf("finalize disabled subscription without recipient dispatch: %v", err) + } + var status string + var canonicalSuccess bool + if err := db.QueryRow(` + SELECT a.status, d.success + FROM webhook_delivery_attempts a + JOIN webhook_deliveries d ON d.queue_attempt_id = a.id + WHERE a.id = $1`, attemptID).Scan(&status, &canonicalSuccess); err != nil { + t.Fatalf("inspect disabled-subscription canonical outcome: %v", err) + } + if status != "dead" || canonicalSuccess || metrics.dead.Load() != 1 { + t.Fatalf("disabled subscription was not terminally finalized: status=%q success=%t dead=%d", status, canonicalSuccess, metrics.dead.Load()) + } +} + +func TestWorkerLeaseWindowConstraintRejectsNonFutureLeasePostgres(t *testing.T) { + db := integrationDatabase(t) + resetWorkerFixtures(t, db) + attemptID, _ := insertAttempt(t, db, "pending") + + _, err := db.Exec(` + UPDATE webhook_delivery_attempts + SET status = 'processing', + claimed_at = now(), + claim_token = $2::uuid, + claim_owner = 'invalid-lease-test', + claim_expires_at = now() + WHERE id = $1`, attemptID, uuid.New().String()) + if err == nil { + t.Fatal("processing lease with a non-future expiry unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "webhook_delivery_attempt_processing_lease_window_check") { + t.Fatalf("unexpected invalid-lease constraint error: %v", err) + } +} diff --git a/workers/go/cmd/webhook_delivery/main_test.go b/workers/go/cmd/webhook_delivery/main_test.go new file mode 100644 index 00000000..4ac1d820 --- /dev/null +++ b/workers/go/cmd/webhook_delivery/main_test.go @@ -0,0 +1,32 @@ +package main + +import "testing" + +func TestValidateWebhookTarget(t *testing.T) { + testCases := []struct { + name string + rawURL string + allowLocalTestTarget bool + wantErr bool + }{ + {name: "permits public HTTPS hostname", rawURL: "https://receiver.example.test/webhooks", wantErr: false}, + {name: "rejects plaintext public target", rawURL: "http://receiver.example.test/webhooks", wantErr: true}, + {name: "rejects private literal target", rawURL: "https://10.0.0.8/webhooks", wantErr: true}, + {name: "rejects loopback literal target", rawURL: "https://127.0.0.1/webhooks", wantErr: true}, + {name: "rejects IPv4-mapped IPv6 loopback target", rawURL: "https://[::ffff:127.0.0.1]/webhooks", wantErr: true}, + {name: "rejects carrier-grade NAT target", rawURL: "https://100.64.0.1/webhooks", wantErr: true}, + {name: "rejects benchmarking target", rawURL: "https://198.18.0.1/webhooks", wantErr: true}, + {name: "rejects localhost hostname", rawURL: "https://localhost/webhooks", wantErr: true}, + {name: "rejects embedded credentials", rawURL: "https://user:password@receiver.example.test/webhooks", wantErr: true}, + {name: "allows only explicit local loopback test double", rawURL: "http://127.0.0.1:8181/webhooks", allowLocalTestTarget: true, wantErr: false}, + {name: "does not allow local hostname test double", rawURL: "http://localhost:8181/webhooks", allowLocalTestTarget: true, wantErr: true}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, err := validateWebhookTarget(testCase.rawURL, testCase.allowLocalTestTarget) + if (err != nil) != testCase.wantErr { + t.Fatalf("validateWebhookTarget(%q, %t) error=%v wantErr=%t", testCase.rawURL, testCase.allowLocalTestTarget, err, testCase.wantErr) + } + }) + } +} diff --git a/workers/python/Dockerfile.ml-foundation b/workers/python/Dockerfile.ml-foundation new file mode 100644 index 00000000..e1303b6f --- /dev/null +++ b/workers/python/Dockerfile.ml-foundation @@ -0,0 +1,18 @@ +FROM python:3.12-slim AS builder +WORKDIR /build +RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ curl && rm -rf /var/lib/apt/lists/* +COPY ml_foundation/requirements.txt /build/requirements.txt +RUN pip install --no-cache-dir --prefix=/install torch==2.13.0+cpu --index-url https://download.pytorch.org/whl/cpu \ + && pip install --no-cache-dir --prefix=/install torch-geometric==2.8.0.post1 ray[train]==2.58.0 duckdb==1.5.5 pyarrow==25.0.1 scikit-learn==1.9.0 'numpy>=1.26,<3' 'fastapi>=0.104,<1' 'uvicorn[standard]>=0.24,<1' 'cryptography>=42,<51' 'pydantic>=2,<3' + +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends curl libgomp1 && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 ndsepml && useradd --uid 10001 --gid 10001 --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin ndsepml +COPY --from=builder /install /usr/local +WORKDIR /app +COPY ml_foundation /app +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 NDSEP_ML_MODEL_DIR=/var/lib/ndsep-ml/models NDSEP_ML_PORT=8251 +USER 10001:10001 +EXPOSE 8251 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl --fail --silent http://127.0.0.1:8251/health || exit 1 +CMD ["python3", "service.py"] diff --git a/workers/python/ml_foundation/MODEL_CARD_SYNTHETIC_CANDIDATE.md b/workers/python/ml_foundation/MODEL_CARD_SYNTHETIC_CANDIDATE.md new file mode 100644 index 00000000..f034c033 --- /dev/null +++ b/workers/python/ml_foundation/MODEL_CARD_SYNTHETIC_CANDIDATE.md @@ -0,0 +1,32 @@ +# NDSEP Synthetic Candidate Model Card + +**Artifact status:** Candidate only. +**Training classification:** Synthetic-only; no production, customer, financial-institution, personal, location, payment or CBN data was used. +**Decision-use status:** **Not approved for production, enforcement, payment, residency, regulatory or adverse decisions.** + +## Validated local run + +| Item | Observed value | +|---|---| +| Synthetic dataset digest | `97bfc6efedca91c4e06ff132688a3f2a377b402c6a9a91b7fbe923887e5c4e51` | +| Event records / graph edges | 1,200 / 221 | +| Tabular model | CPU PyTorch `event_risk_mlp` | +| Tabular artifact SHA-256 | `4c21b49d3dccd4983a77cb229f14826ea77736a63827a5eb2307b6127568a77f` | +| Tabular time-split test metrics | Accuracy 0.755556; F1 0.421053; precision 0.307692; recall 0.666667 | +| Graph model | CPU PyTorch-Geometric `organization_graphsage` | +| Graph artifact SHA-256 | `1d3e6a3b719e1cecbab4d3849b92a91c94bfd2a8132b80abf5182566c7bba982` | +| Graph synthetic test metrics | Accuracy 0.750000; F1 0.750000; precision 0.750000; recall 0.750000 | +| Artifact integrity result | Ed25519 manifest signature and artifact SHA-256 values verified | +| CPU inference smoke result | Service `ready`; verified MLP returned `human_review_required` | + +## Intended use and non-use + +The tabular model predicts only the **synthetic generator’s simulated review label**. The graph model predicts a synthetic organization-level proxy. Neither output represents fraud, non-compliance, data residency, risk, sanctions, a real organization or a real person. The models can only be used to validate the training, provenance, serving and human-review workflow. + +The API returns a model score with `recommended_workflow: human_review_required` and a list of prohibited uses. It fails unavailable for a missing, changed or unsigned model; it does not fall back to a rule or fabricated result. + +## Evidence and reproduction + +The validated evidence bundle is retained under `/home/ubuntu/ndsep-ml-foundation-validation/` for this session, including the synthetic data manifest, signed model manifest, weights, training result and CPU inference result. The private key in that local bundle is **test-only**, was not committed and must not be reused. + +Reproduce using `README.md` and retain the generated model manifest, data manifest, metrics and environment/dependency lock evidence. A production candidate requires a controlled signing service, immutable model registry, governed data contract, independent evaluation and accountable approval. diff --git a/workers/python/ml_foundation/README.md b/workers/python/ml_foundation/README.md new file mode 100644 index 00000000..ecc3efc8 --- /dev/null +++ b/workers/python/ml_foundation/README.md @@ -0,0 +1,84 @@ +# NDSEP ML Foundation: Synthetic-Only CPU Candidate Models + +This package is a **working candidate-model foundation**, not a production fraud, residency, compliance or enforcement engine. It creates deterministic synthetic data, writes partitioned Parquet in a Lakehouse-shaped layout, trains real PyTorch and PyTorch-Geometric weights on CPU, signs a candidate model manifest with Ed25519 and refuses inference when an artifact is unsigned, altered or unavailable. + +> **Non-negotiable boundary:** The generated labels are analytically simulated. A score may support an accountable human review in a sandbox only. It must never trigger a penalty, payment, account action, residency determination, regulatory conclusion, sanctions decision or adverse action. + +## Architecture + +| Layer | Implemented behavior | Deliberate restriction | +|---|---|---| +| Synthetic data | Deterministic multi-tenant event and graph generator; partitioned Zstandard Parquet; content-hashed manifest | No real customer, institution, payment, geolocation, personal, regulated or production data is read. | +| Lakehouse access | DuckDB trains from partitioned Parquet and verifies every manifest file hash before load | This is Iceberg-compatible file layout, not a deployed governed Iceberg catalog. | +| ML/DL | CPU PyTorch MLP with optimizer, BCE loss, backpropagation, early stopping, time split, checkpoint and metrics | Candidate only; metrics on synthetic data do not transfer to real-world accuracy. | +| GNN | CPU PyTorch-Geometric GraphSAGE with real message passing and training loop | Graph contains only generated organization IDs/edges; no production graph store is connected. | +| Ray | `ray_pipeline.py` dispatches MLP and GraphSAGE training to separate Ray CPU tasks; signing happens only on driver | This is distributed candidate-task execution, not proof of a governed Ray cluster or distributed data-parallel production training. | +| Artifact integrity | Ed25519-signed `model-manifest.json`; SHA-256 artifact/data hashes; `torch.load(..., weights_only=True)` | Test key is for local validation only. Production requires a separately integrated, non-exportable signing service/HSM. | +| Serving | FastAPI worker verifies manifest signature + hashes before CPU inference and returns `human_review_required` | No train/promotion/enforcement endpoint is exposed. | +| Continuous approach | `continuous_candidate.py` executes one bounded cycle for an external scheduler and writes run evidence | No daemon, automatic promotion or access to base-platform data exists. | + +## Local verified run + +Use an empty non-production directory and a non-production Ed25519 key. Never place the key in Git, a model image, CI logs or a shared data volume. + +```bash +set -euo pipefail +ROOT="$PWD/.ml-foundation-local" +rm -rf "$ROOT" +mkdir -p "$ROOT/keys" "$ROOT/lakehouse" "$ROOT/models" "$ROOT/evidence" +openssl genpkey -algorithm ED25519 -out "$ROOT/keys/candidate-model-signing-key.pem" +python3 synthetic_data.py --output-dir "$ROOT/lakehouse" --events 1200 --tenants 6 --days 90 --seed 20260830 +MANIFEST="$(find "$ROOT/lakehouse/manifests" -type f -name 'synthetic-*.json' -print -quit)" +python3 train.py --lakehouse-dir "$ROOT/lakehouse" --data-manifest "$MANIFEST" --output-dir "$ROOT/models" --signing-key "$ROOT/keys/candidate-model-signing-key.pem" --seed 20260830 +python3 verify_artifacts.py --model-dir "$ROOT/models" +``` + +Expected verifier shape: + +```json +{"status":"verified","device":"cpu","model":"event_risk_mlp","workflow":"human_review_required"} +``` + +Start the candidate-only API only on a non-public loopback interface: + +```bash +NDSEP_ML_MODEL_DIR="$ROOT/models" NDSEP_ML_PORT=8251 python3 service.py +curl --fail --silent http://127.0.0.1:8251/health +``` + +## Ray CPU candidate tasks + +The following command starts a **local** two-CPU Ray runtime. On an approved managed/cluster Ray deployment, provide `--ray-address` with a private authenticated address and use workload identity, network policy, encrypted object storage and a remote signing service; do not send a private signing key to Ray workers. + +```bash +python3 ray_pipeline.py \ + --lakehouse-dir "$ROOT/ray-lakehouse" \ + --model-output-dir "$ROOT/ray-models" \ + --signing-key "$ROOT/keys/candidate-model-signing-key.pem" \ + --events 1200 --tenants 6 --days 90 --seed 20260830 +``` + +## Continuous candidate cycles + +A scheduler may invoke one immutable candidate cycle. The scheduler must supply a distinct run directory and retain the resulting evidence; it must not mark a model as deployed. + +```bash +python3 continuous_candidate.py \ + --lakehouse-dir "$ROOT/candidate-lakehouse" \ + --model-output-dir "$ROOT/candidate-models" \ + --run-evidence-dir "$ROOT/evidence" \ + --signing-key "$ROOT/keys/candidate-model-signing-key.pem" \ + --events 6000 --tenants 12 --days 180 --seed 20260830 +``` + +## Path to governed platform-data training + +Before adding a governed real-data adapter, require: legal authority and data-minimization approval; tenant-scoped data contract; irreversible de-identification or approved privacy-preserving treatment; a versioned Iceberg catalog/data-quality contract; immutable dataset snapshot and lineage; time-based and tenant-aware evaluation; data leakage, bias and performance validation; model registry; independent signature/attestation; approval by accountable model-risk and compliance owners; monitoring; and rollback. Existing base-platform data must not be read merely because it is technically accessible. + +## Tests + +```bash +python3 test_ml_foundation.py +``` + +The test creates temporary data and key material, trains both models, verifies artifacts and deletes the temporary directory. It does not connect to PostgreSQL, Redis, Kafka, TigerBeetle, Mojaloop, CBN, NIBSS, Keycloak, Ray cluster or any external service. diff --git a/workers/python/ml_foundation/continuous_candidate.py b/workers/python/ml_foundation/continuous_candidate.py new file mode 100644 index 00000000..c4f3882b --- /dev/null +++ b/workers/python/ml_foundation/continuous_candidate.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Bounded continuous candidate-training controller. + +This controller is intentionally an explicit one-shot job suitable for an external +scheduler. It does not run an unbounded daemon, auto-promote models, mutate +operational decisions or ingest regulated platform data. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + +from pipeline import main as pipeline_main + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run one governed NDSEP candidate training cycle") + parser.add_argument("--lakehouse-dir", required=True, type=Path) + parser.add_argument("--model-output-dir", required=True, type=Path) + parser.add_argument("--signing-key", required=True, type=Path) + parser.add_argument("--run-evidence-dir", required=True, type=Path) + parser.add_argument("--events", default=6000, type=int) + parser.add_argument("--tenants", default=12, type=int) + parser.add_argument("--days", default=180, type=int) + parser.add_argument("--seed", default=20260830, type=int) + args = parser.parse_args() + + # Rebuild argv for the strict one-shot candidate pipeline; no hidden data source. + import sys + sys.argv = [ + "pipeline.py", "--lakehouse-dir", str(args.lakehouse_dir), "--model-output-dir", str(args.model_output_dir), + "--signing-key", str(args.signing_key), "--events", str(args.events), "--tenants", str(args.tenants), + "--days", str(args.days), "--seed", str(args.seed), + ] + pipeline_main() + model_manifest = args.model_output_dir / "model-manifest.json" + if not model_manifest.is_file(): + raise RuntimeError("candidate pipeline did not produce a signed model manifest") + raw = model_manifest.read_bytes() + evidence = { + "schema_version": "ndsep.ml.candidate-run.v1", + "created_at": datetime.now(timezone.utc).isoformat(), + "run_mode": "candidate_only", + "source_classification": "synthetic_only", + "model_manifest_path": str(model_manifest.resolve()), + "model_manifest_sha256": hashlib.sha256(raw).hexdigest(), + "automatic_promotion": False, + "required_next_control": "independent_evaluation_and_human_approval", + } + args.run_evidence_dir.mkdir(parents=True, exist_ok=True) + destination = args.run_evidence_dir / f"candidate-run-{evidence['model_manifest_sha256'][:16]}.json" + destination.write_text(json.dumps(evidence, sort_keys=True, indent=2) + "\n") + print(json.dumps({"status": "candidate_cycle_recorded", "evidence_path": str(destination), **evidence}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/workers/python/ml_foundation/pipeline.py b/workers/python/ml_foundation/pipeline.py new file mode 100644 index 00000000..e7d16b4a --- /dev/null +++ b/workers/python/ml_foundation/pipeline.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Candidate-only NDSEP ML pipeline. + +This is a deliberately bounded training entry point. It generates synthetic data, +trains candidate artifacts and exits. A scheduler may invoke it, but it cannot +self-promote a model or access platform data without a separately approved adapter. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from synthetic_data import GenerationConfig, write_lakehouse +from train import train_all + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run one NDSEP synthetic-only candidate training cycle") + parser.add_argument("--lakehouse-dir", required=True, type=Path) + parser.add_argument("--model-output-dir", required=True, type=Path) + parser.add_argument("--signing-key", required=True, type=Path) + parser.add_argument("--events", default=6000, type=int) + parser.add_argument("--tenants", default=12, type=int) + parser.add_argument("--days", default=180, type=int) + parser.add_argument("--seed", default=20260830, type=int) + args = parser.parse_args() + data = write_lakehouse(args.lakehouse_dir, GenerationConfig(args.seed, args.events, args.tenants, args.days)) + trained = train_all(args.lakehouse_dir, Path(data["manifest_path"]), args.model_output_dir, args.signing_key, args.seed) + print(json.dumps({"status": "candidate_cycle_completed", "dataset": data, "training": trained}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/workers/python/ml_foundation/ray_pipeline.py b/workers/python/ml_foundation/ray_pipeline.py new file mode 100644 index 00000000..cb35ddb0 --- /dev/null +++ b/workers/python/ml_foundation/ray_pipeline.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Ray launcher for NDSEP synthetic-only candidate training. + +The driver creates one synthetic dataset manifest, then executes independent MLP and +GraphSAGE candidate training workloads through Ray. Artifact signing remains on the +driver, avoiding distribution of the model-signing private key to workers. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import ray +import torch + +from synthetic_data import GenerationConfig, write_lakehouse +from train import ( + FEATURE_NAMES, + _event_matrix, + _load_manifest, + _write_signed_manifest, + set_seed, + sha256_path, + train_event_model, + train_graph_model, +) + + +@ray.remote(num_cpus=1) +def train_event_remote(lakehouse_dir: str, data_manifest_path: str, seed: int) -> dict[str, Any]: + set_seed(seed) + manifest = _load_manifest(Path(data_manifest_path)) + features, labels, _timestamps, _tenants = _event_matrix(Path(lakehouse_dir), manifest) + model, metrics, scaler = train_event_model(features, labels, seed) + return {"state_dict": model.state_dict(), "metrics": metrics, "scaler": scaler} + + +@ray.remote(num_cpus=1) +def train_graph_remote(lakehouse_dir: str, seed: int) -> dict[str, Any]: + set_seed(seed + 1) + model, metrics, node_ids = train_graph_model(Path(lakehouse_dir), seed + 1) + return {"state_dict": model.state_dict(), "metrics": metrics, "node_ids": node_ids} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run Ray candidate training for NDSEP synthetic-only data") + parser.add_argument("--lakehouse-dir", required=True, type=Path) + parser.add_argument("--model-output-dir", required=True, type=Path) + parser.add_argument("--signing-key", required=True, type=Path) + parser.add_argument("--events", default=6000, type=int) + parser.add_argument("--tenants", default=12, type=int) + parser.add_argument("--days", default=180, type=int) + parser.add_argument("--seed", default=20260830, type=int) + parser.add_argument("--ray-address", default=None, help="Approved Ray address; omit for local two-CPU Ray runtime") + args = parser.parse_args() + + data = write_lakehouse(args.lakehouse_dir, GenerationConfig(args.seed, args.events, args.tenants, args.days)) + ray.init(address=args.ray_address or None, num_cpus=2 if args.ray_address is None else None, ignore_reinit_error=True) + event_result, graph_result = ray.get([ + train_event_remote.remote(str(args.lakehouse_dir), data["manifest_path"], args.seed), + train_graph_remote.remote(str(args.lakehouse_dir), args.seed), + ]) + output = args.model_output_dir + output.mkdir(parents=True, exist_ok=True) + event_path = output / "event_risk_mlp.pt" + graph_path = output / "organization_graphsage.pt" + torch.save({"state_dict": event_result["state_dict"], "feature_names": FEATURE_NAMES, "scaler": event_result["scaler"]}, event_path) + torch.save({"state_dict": graph_result["state_dict"], "node_ids": graph_result["node_ids"]}, graph_path) + entries = [ + {"name": "event_risk_mlp", "framework": "pytorch", "device": "cpu", "artifact": event_path.name, + "sha256": sha256_path(event_path), "metrics": event_result["metrics"], "input_schema": FEATURE_NAMES}, + {"name": "organization_graphsage", "framework": "pytorch_geometric", "device": "cpu", "artifact": graph_path.name, + "sha256": sha256_path(graph_path), "metrics": graph_result["metrics"], "input_schema": ["organization_graph"]}, + ] + manifest = _load_manifest(Path(data["manifest_path"])) + manifest["manifest_path"] = data["manifest_path"] + model_manifest = _write_signed_manifest(output, manifest, entries, args.signing_key) + print(json.dumps({"status": "ray_candidate_cycle_completed", "ray_resources": ray.cluster_resources(), "dataset": data, + "model_manifest": str(model_manifest), "models": entries}, sort_keys=True)) + ray.shutdown() + + +if __name__ == "__main__": + main() diff --git a/workers/python/ml_foundation/requirements.txt b/workers/python/ml_foundation/requirements.txt new file mode 100644 index 00000000..098e4601 --- /dev/null +++ b/workers/python/ml_foundation/requirements.txt @@ -0,0 +1,17 @@ +# NDSEP ML Foundation — CPU-only candidate training +# Production deployment must install from a locked, hash-pinned requirements file. +torch==2.13.0+cpu +torch-geometric==2.8.0.post1 +ray[train]==2.58.0 +duckdb==1.5.5 +pyarrow==25.0.1 +numpy>=1.26,<3 +scikit-learn==1.9.0 +fastapi>=0.104,<1 +uvicorn[standard]>=0.24,<1 +cryptography>=42,<51 +pydantic>=2,<3 + +# This package intentionally does not depend on Neo4j, cloud credentials, or a +# regulated source system. It is synthetic-only until an approved governed +# Lakehouse dataset contract is separately implemented. diff --git a/workers/python/ml_foundation/service.py b/workers/python/ml_foundation/service.py new file mode 100644 index 00000000..e7405c26 --- /dev/null +++ b/workers/python/ml_foundation/service.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fail-closed CPU inference service for signed NDSEP candidate models. + +This service intentionally exposes decision support only. It rejects unsigned, +missing, altered, production-labelled or non-synthetic candidate artifacts and +never returns an enforcement instruction. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +from train import EventRiskMLP, FEATURE_NAMES, canonical_json, sha256_path + +MODEL_DIR = Path(os.environ.get("NDSEP_ML_MODEL_DIR", "/var/lib/ndsep-ml/models")) +MANIFEST_PATH = MODEL_DIR / "model-manifest.json" +DEVICE = torch.device("cpu") + +app = FastAPI(title="NDSEP Candidate ML Foundation", version="1.0.0") + + +class EventPredictionRequest(BaseModel): + amount_ngn_equivalent: float = Field(ge=0, le=10_000_000) + velocity_24h: int = Field(ge=0, le=10_000) + device_age_days: int = Field(ge=0, le=20_000) + country_risk_band: int = Field(ge=0, le=2) + cross_border: bool + prior_review_count: int = Field(ge=0, le=10_000) + failed_auth_24h: int = Field(ge=0, le=10_000) + unusual_hour: int = Field(ge=0, le=1) + purpose: str = Field(pattern="^decision_support$") + + +def _load_verified_model() -> tuple[EventRiskMLP, dict[str, Any], dict[str, Any]]: + if not MANIFEST_PATH.is_file(): + raise RuntimeError("candidate model manifest is unavailable") + manifest = json.loads(MANIFEST_PATH.read_text()) + if manifest.get("status") != "candidate_only" or manifest.get("classification") != "synthetic_training_only": + raise RuntimeError("model manifest is not an approved synthetic candidate") + expected_manifest_hash = manifest.get("manifest_sha256") + digest_copy = dict(manifest) + digest_copy.pop("manifest_sha256", None) + if expected_manifest_hash != hashlib.sha256(canonical_json(digest_copy)).hexdigest(): + raise RuntimeError("model manifest digest mismatch") + signing = dict(manifest.get("signing") or {}) + signature_hex = signing.pop("signature_b64", "") + public_key_hex = signing.get("public_key_b64", "") + verify_copy = dict(manifest) + verify_copy.pop("manifest_sha256", None) + verify_signing = dict(verify_copy["signing"]) + verify_signing.pop("signature_b64", None) + verify_copy["signing"] = verify_signing + try: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex)).verify(bytes.fromhex(signature_hex), canonical_json(verify_copy)) + except Exception as error: + raise RuntimeError("model manifest signature verification failed") from error + entry = next((record for record in manifest.get("models", []) if record.get("name") == "event_risk_mlp"), None) + if not entry: + raise RuntimeError("event model entry is missing") + artifact_path = MODEL_DIR / str(entry.get("artifact", "")) + if not artifact_path.is_file() or sha256_path(artifact_path) != entry.get("sha256"): + raise RuntimeError("event model artifact hash verification failed") + checkpoint = torch.load(artifact_path, map_location=DEVICE, weights_only=True) + if checkpoint.get("feature_names") != FEATURE_NAMES: + raise RuntimeError("event model feature schema does not match service schema") + model = EventRiskMLP(len(FEATURE_NAMES)).to(DEVICE) + model.load_state_dict(checkpoint["state_dict"]) + model.eval() + return model, checkpoint["scaler"], {"manifest": manifest, "entry": entry} + + +@app.get("/health") +def health() -> dict[str, Any]: + try: + _model, _scaler, data = _load_verified_model() + return {"status": "ready", "device": "cpu", "model": data["entry"]["name"], "model_sha256": data["entry"]["sha256"], "mode": "candidate_only"} + except RuntimeError as error: + return {"status": "unavailable", "reason": str(error), "mode": "candidate_only"} + + +@app.get("/v1/models") +def model_info() -> dict[str, Any]: + try: + _model, _scaler, data = _load_verified_model() + except RuntimeError as error: + raise HTTPException(status_code=503, detail="verified candidate model unavailable") from error + entry = data["entry"] + return {"status": "candidate_only", "model": {"name": entry["name"], "framework": entry["framework"], "sha256": entry["sha256"], "metrics": entry["metrics"]}, "training_dataset_sha256": data["manifest"]["training_dataset_sha256"]} + + +@app.post("/v1/predict/event") +def predict_event(request: EventPredictionRequest) -> dict[str, Any]: + try: + model, scaler, data = _load_verified_model() + except RuntimeError as error: + raise HTTPException(status_code=503, detail="verified candidate model unavailable") from error + raw = np.asarray([[getattr(request, feature) for feature in FEATURE_NAMES]], dtype=np.float32) + mean = np.asarray(scaler["mean"], dtype=np.float32) + std = np.asarray(scaler["std"], dtype=np.float32) + scaled = torch.tensor((raw - mean) / std, dtype=torch.float32, device=DEVICE) + with torch.no_grad(): + probability = float(torch.sigmoid(model(scaled)).item()) + return { + "status": "candidate_only", + "model_name": data["entry"]["name"], + "model_sha256": data["entry"]["sha256"], + "training_dataset_sha256": data["manifest"]["training_dataset_sha256"], + "simulated_review_probability": round(probability, 6), + "recommended_workflow": "human_review_required", + "prohibited_uses": ["automated_enforcement", "payment_action", "residency_assertion", "regulatory_finding", "adverse_decision"], + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("NDSEP_ML_PORT", "8251"))) diff --git a/workers/python/ml_foundation/synthetic_data.py b/workers/python/ml_foundation/synthetic_data.py new file mode 100644 index 00000000..b56a1f49 --- /dev/null +++ b/workers/python/ml_foundation/synthetic_data.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Deterministic, synthetic-only data generator for NDSEP candidate-model training. + +This module never connects to production databases or external financial systems. The +schema resembles compliance-event telemetry only at an abstract level. Tenant, +organization, device and transaction identifiers are generated and contain no +personal, institutional or regulated data. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import random +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq + +SCHEMA_VERSION = "ndsep.synthetic.compliance.v1" +GENERATOR_VERSION = "1.0.0" + + +@dataclass(frozen=True) +class GenerationConfig: + seed: int = 20260830 + events: int = 6000 + tenants: int = 12 + days: int = 180 + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _event_id(seed: int, ordinal: int) -> str: + return hashlib.sha256(f"ndsep-synthetic:{seed}:{ordinal}".encode()).hexdigest()[:32] + + +def generate_dataset(config: GenerationConfig) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Generate reproducible events and a safe organization-to-organization graph. + + Labels are generated from documented synthetic risk mechanisms. They describe a + *simulation* target only and must never be portrayed as fraud, compliance or + residency labels for real entities. + """ + if config.events < 500: + raise ValueError("events must be at least 500 for a stratified time split") + if config.tenants < 2: + raise ValueError("tenants must be at least 2") + if config.days < 30: + raise ValueError("days must be at least 30") + + rng = np.random.default_rng(config.seed) + chooser = random.Random(config.seed) + start = datetime(2025, 1, 1, tzinfo=timezone.utc) + organization_count = config.tenants * 8 + organizations = [f"org-{index:04d}" for index in range(organization_count)] + tenant_for_org = {org: f"tenant-{index // 8 + 1:03d}" for index, org in enumerate(organizations)} + + events: list[dict[str, Any]] = [] + for ordinal in range(config.events): + origin = organizations[int(rng.integers(0, organization_count))] + destination = organizations[int(rng.integers(0, organization_count))] + while destination == origin: + destination = organizations[int(rng.integers(0, organization_count))] + + day_offset = int(rng.integers(0, config.days)) + minute_offset = int(rng.integers(0, 24 * 60)) + observed_at = start + timedelta(days=day_offset, minutes=minute_offset) + amount = float(np.round(rng.lognormal(mean=7.25, sigma=1.0), 2)) + velocity = int(rng.poisson(2.2 + (amount > 4000) * 2.0)) + device_age_days = int(rng.integers(0, 900)) + country_risk = int(rng.choice([0, 1, 2], p=[0.78, 0.17, 0.05])) + cross_border = bool(rng.random() < 0.16) + prior_review_count = int(rng.poisson(0.6)) + failed_auth_24h = int(rng.poisson(0.35 + (device_age_days < 7) * 1.8)) + hour = observed_at.hour + unusual_hour = int(hour < 5 or hour > 22) + + # Deliberately documented simulated causal process, plus irreducible noise. + logit = ( + -4.2 + + 0.00018 * min(amount, 50000) + + 0.24 * velocity + + 0.82 * int(device_age_days < 7) + + 0.62 * cross_border + + 0.44 * country_risk + + 0.41 * prior_review_count + + 0.53 * failed_auth_24h + + 0.27 * unusual_hour + + float(rng.normal(0, 0.65)) + ) + simulated_review_label = int(rng.random() < 1.0 / (1.0 + math.exp(-logit))) + events.append({ + "event_id": _event_id(config.seed, ordinal), + "tenant_id": tenant_for_org[origin], + "organization_id": origin, + "counterparty_organization_id": destination, + "observed_at": observed_at.isoformat(), + "event_day": observed_at.date().isoformat(), + "amount_ngn_equivalent": amount, + "velocity_24h": velocity, + "device_age_days": device_age_days, + "country_risk_band": country_risk, + "cross_border": cross_border, + "prior_review_count": prior_review_count, + "failed_auth_24h": failed_auth_24h, + "unusual_hour": unusual_hour, + "simulated_review_label": simulated_review_label, + "dataset_classification": "synthetic_only", + "generator_version": GENERATOR_VERSION, + }) + + # The graph is derived solely from the generated events. It is a second dataset, + # not an unsupported claim about real network or money movement. + edge_counts: dict[tuple[str, str], dict[str, Any]] = {} + for event in events: + key = (event["organization_id"], event["counterparty_organization_id"]) + row = edge_counts.setdefault(key, { + "source_organization_id": key[0], + "target_organization_id": key[1], + "event_count": 0, + "simulated_review_count": 0, + "cross_border_count": 0, + }) + row["event_count"] += 1 + row["simulated_review_count"] += event["simulated_review_label"] + row["cross_border_count"] += int(event["cross_border"]) + + edges = [] + for edge in edge_counts.values(): + if edge["event_count"] >= 2: + edges.append({ + **edge, + "source_tenant_id": tenant_for_org[edge["source_organization_id"]], + "target_tenant_id": tenant_for_org[edge["target_organization_id"]], + "dataset_classification": "synthetic_only", + }) + chooser.shuffle(edges) + return events, edges + + +def write_lakehouse(output_dir: Path, config: GenerationConfig) -> dict[str, Any]: + """Write partitioned Parquet + immutable manifest without external services.""" + output_dir = output_dir.resolve() + events, edges = generate_dataset(config) + dataset_root = output_dir / "bronze" / "synthetic_compliance_events" + graph_root = output_dir / "bronze" / "synthetic_compliance_graph_edges" + dataset_root.mkdir(parents=True, exist_ok=True) + graph_root.mkdir(parents=True, exist_ok=True) + + event_files: list[Path] = [] + partitions: dict[tuple[str, str], list[dict[str, Any]]] = {} + for event in events: + partitions.setdefault((event["tenant_id"], event["event_day"]), []).append(event) + for (tenant_id, event_day), rows in sorted(partitions.items()): + destination = dataset_root / f"tenant_id={tenant_id}" / f"event_day={event_day}" + destination.mkdir(parents=True, exist_ok=True) + path = destination / "events.parquet" + pq.write_table(pa.Table.from_pylist(rows), path, compression="zstd") + event_files.append(path) + + graph_path = graph_root / "graph_edges.parquet" + pq.write_table(pa.Table.from_pylist(edges), graph_path, compression="zstd") + manifest = { + "schema_version": SCHEMA_VERSION, + "generator_version": GENERATOR_VERSION, + "classification": "synthetic_only", + "generated_at": datetime.now(timezone.utc).isoformat(), + "generation_config": asdict(config), + "event_count": len(events), + "edge_count": len(edges), + "label_name": "simulated_review_label", + "label_notice": "This is an analytically generated simulation label and is not a regulatory, fraud, residency, sanctions, KYC or adverse-decision label.", + "files": [ + {"path": str(path.relative_to(output_dir)), "sha256": sha256_file(path)} + for path in [*event_files, graph_path] + ], + } + manifest_payload = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + manifest["dataset_sha256"] = hashlib.sha256(manifest_payload).hexdigest() + manifest_path = output_dir / "manifests" / f"synthetic-{manifest['dataset_sha256'][:16]}.json" + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + return {"manifest_path": str(manifest_path), **manifest} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate deterministic NDSEP synthetic-only Lakehouse training data") + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--events", default=6000, type=int) + parser.add_argument("--tenants", default=12, type=int) + parser.add_argument("--days", default=180, type=int) + parser.add_argument("--seed", default=20260830, type=int) + args = parser.parse_args() + result = write_lakehouse(args.output_dir, GenerationConfig(args.seed, args.events, args.tenants, args.days)) + print(json.dumps({ + "status": "created", + "classification": result["classification"], + "dataset_sha256": result["dataset_sha256"], + "event_count": result["event_count"], + "edge_count": result["edge_count"], + "manifest_path": result["manifest_path"], + }, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/workers/python/ml_foundation/test_ml_foundation.py b/workers/python/ml_foundation/test_ml_foundation.py new file mode 100644 index 00000000..c2e675bb --- /dev/null +++ b/workers/python/ml_foundation/test_ml_foundation.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from synthetic_data import GenerationConfig, write_lakehouse +from train import train_all + + +class MlFoundationEndToEndTest(unittest.TestCase): + def test_synthetic_train_sign_and_cpu_infer(self) -> None: + with tempfile.TemporaryDirectory(prefix="ndsep-ml-foundation-") as temporary: + root = Path(temporary) + lakehouse = root / "lakehouse" + models = root / "models" + key_path = root / "candidate-key.pem" + private_key = Ed25519PrivateKey.generate() + key_path.write_bytes(private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + generated = write_lakehouse(lakehouse, GenerationConfig(seed=11, events=600, tenants=4, days=60)) + self.assertEqual(generated["classification"], "synthetic_only") + self.assertEqual(generated["event_count"], 600) + self.assertGreater(generated["edge_count"], 0) + trained = train_all(lakehouse, Path(generated["manifest_path"]), models, key_path, 11) + self.assertEqual(trained["status"], "candidate_trained") + self.assertTrue((models / "event_risk_mlp.pt").is_file()) + self.assertTrue((models / "organization_graphsage.pt").is_file()) + manifest = json.loads((models / "model-manifest.json").read_text()) + self.assertEqual(manifest["status"], "candidate_only") + self.assertEqual(manifest["classification"], "synthetic_training_only") + self.assertEqual(len(manifest["models"]), 2) + self.assertTrue(all(entry["sha256"] for entry in manifest["models"])) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/workers/python/ml_foundation/train.py b/workers/python/ml_foundation/train.py new file mode 100644 index 00000000..89f31078 --- /dev/null +++ b/workers/python/ml_foundation/train.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""CPU-only candidate training for NDSEP synthetic Lakehouse data. + +The program trains two real models: a PyTorch MLP on event features and a +PyTorch-Geometric GraphSAGE model on the generated organization graph. It is +candidate-only: no output changes a compliance, payment, residency, sanctions, +fraud or enforcement decision. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import random +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import duckdb +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from torch_geometric.data import Data +from torch_geometric.nn import SAGEConv + +FEATURE_NAMES = [ + "amount_ngn_equivalent", "velocity_24h", "device_age_days", + "country_risk_band", "cross_border", "prior_review_count", + "failed_auth_24h", "unusual_hour", +] +MODEL_SCHEMA_VERSION = "ndsep.ml.candidate.v1" +DEVICE = torch.device("cpu") + + +def canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def sha256_path(path: Path) -> str: + hash_value = hashlib.sha256() + with path.open("rb") as fh: + for block in iter(lambda: fh.read(1024 * 1024), b""): + hash_value.update(block) + return hash_value.hexdigest() + + +def set_seed(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.use_deterministic_algorithms(True, warn_only=True) + + +class EventRiskMLP(nn.Module): + def __init__(self, inputs: int) -> None: + super().__init__() + self.network = nn.Sequential( + nn.Linear(inputs, 32), nn.ReLU(), nn.Dropout(0.10), + nn.Linear(32, 16), nn.ReLU(), nn.Linear(16, 1), + ) + + def forward(self, values: torch.Tensor) -> torch.Tensor: + return self.network(values).squeeze(-1) + + +class OrganizationGraphSAGE(nn.Module): + def __init__(self, inputs: int) -> None: + super().__init__() + self.layer_one = SAGEConv(inputs, 24) + self.layer_two = SAGEConv(24, 12) + self.output = nn.Linear(12, 1) + + def forward(self, x: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor: + x = F.relu(self.layer_one(x, edge_index)) + x = F.dropout(x, p=0.10, training=self.training) + x = F.relu(self.layer_two(x, edge_index)) + return self.output(x).squeeze(-1) + + +def _load_manifest(path: Path) -> dict[str, Any]: + manifest = json.loads(path.read_text()) + if manifest.get("classification") != "synthetic_only": + raise ValueError("candidate trainer permits only classification=synthetic_only") + required = {"dataset_sha256", "files", "schema_version", "event_count", "label_name"} + missing = required - set(manifest) + if missing: + raise ValueError(f"dataset manifest missing: {sorted(missing)}") + return manifest + + +def _verify_data_manifest(lakehouse_dir: Path, manifest: dict[str, Any]) -> None: + files = manifest.get("files", []) + if not files: + raise ValueError("dataset manifest has no data files") + for record in files: + path = lakehouse_dir / record["path"] + if not path.is_file(): + raise ValueError(f"dataset file missing: {path}") + actual = sha256_path(path) + if actual != record["sha256"]: + raise ValueError(f"dataset file hash mismatch: {record['path']}") + + +def _event_matrix(lakehouse_dir: Path, manifest: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, list[str], list[str]]: + _verify_data_manifest(lakehouse_dir, manifest) + events_glob = str(lakehouse_dir / "bronze" / "synthetic_compliance_events" / "**" / "*.parquet") + query = f""" + SELECT {', '.join(FEATURE_NAMES)}, simulated_review_label, observed_at, tenant_id + FROM read_parquet('{events_glob}', hive_partitioning=true) + ORDER BY observed_at ASC, event_id ASC + """ + connection = duckdb.connect(":memory:") + rows = connection.execute(query).fetchall() + connection.close() + if len(rows) < 500: + raise ValueError("at least 500 synthetic records are required") + features = np.asarray([[float(item[index]) for index in range(len(FEATURE_NAMES))] for item in rows], dtype=np.float32) + labels = np.asarray([float(item[len(FEATURE_NAMES)]) for item in rows], dtype=np.float32) + timestamps = [str(item[len(FEATURE_NAMES) + 1]) for item in rows] + tenants = [str(item[len(FEATURE_NAMES) + 2]) for item in rows] + return features, labels, timestamps, tenants + + +def _standardize_train(features: np.ndarray, train_end: int) -> tuple[np.ndarray, dict[str, list[float]]]: + means = features[:train_end].mean(axis=0) + stds = features[:train_end].std(axis=0) + stds = np.where(stds < 1e-6, 1.0, stds) + return ((features - means) / stds).astype(np.float32), {"mean": means.tolist(), "std": stds.tolist()} + + +def _metrics(logits: torch.Tensor, labels: torch.Tensor) -> dict[str, float]: + with torch.no_grad(): + probabilities = torch.sigmoid(logits) + predictions = (probabilities >= 0.5).float() + accuracy = float((predictions == labels).float().mean().item()) + true_positive = float(((predictions == 1) & (labels == 1)).sum().item()) + false_positive = float(((predictions == 1) & (labels == 0)).sum().item()) + false_negative = float(((predictions == 0) & (labels == 1)).sum().item()) + precision = true_positive / max(true_positive + false_positive, 1.0) + recall = true_positive / max(true_positive + false_negative, 1.0) + f1 = 2 * precision * recall / max(precision + recall, 1e-12) + return {"accuracy": round(accuracy, 6), "precision": round(precision, 6), "recall": round(recall, 6), "f1": round(f1, 6)} + + +def train_event_model(features: np.ndarray, labels: np.ndarray, seed: int) -> tuple[EventRiskMLP, dict[str, Any], dict[str, list[float]]]: + total = len(features) + train_end = int(total * 0.70) + validation_end = int(total * 0.85) + standardized, scaler = _standardize_train(features, train_end) + x = torch.tensor(standardized, dtype=torch.float32, device=DEVICE) + y = torch.tensor(labels, dtype=torch.float32, device=DEVICE) + model = EventRiskMLP(x.shape[1]).to(DEVICE) + positives = max(float(y[:train_end].sum().item()), 1.0) + negatives = max(float(train_end) - positives, 1.0) + loss_fn = nn.BCEWithLogitsLoss(pos_weight=torch.tensor(negatives / positives, device=DEVICE)) + optimizer = torch.optim.AdamW(model.parameters(), lr=0.002, weight_decay=1e-4) + best_state: dict[str, torch.Tensor] | None = None + best_validation = float("inf") + stale_epochs = 0 + history: list[float] = [] + for epoch in range(80): + model.train() + optimizer.zero_grad() + loss = loss_fn(model(x[:train_end]), y[:train_end]) + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + optimizer.step() + model.eval() + with torch.no_grad(): + validation_loss = float(loss_fn(model(x[train_end:validation_end]), y[train_end:validation_end]).item()) + history.append(validation_loss) + if validation_loss < best_validation - 1e-6: + best_validation = validation_loss + best_state = {name: value.detach().cpu().clone() for name, value in model.state_dict().items()} + stale_epochs = 0 + else: + stale_epochs += 1 + if stale_epochs >= 12: + break + if best_state is None: + raise RuntimeError("event model did not produce a checkpoint") + model.load_state_dict(best_state) + model.eval() + with torch.no_grad(): + test_logits = model(x[validation_end:]) + metrics = { + "task": "synthetic_event_review_classification", + "split": {"strategy": "time_ordered", "train": train_end, "validation": validation_end - train_end, "test": total - validation_end}, + "test": _metrics(test_logits, y[validation_end:]), + "validation_bce": round(best_validation, 6), + "epochs": len(history), + "feature_names": FEATURE_NAMES, + "seed": seed, + } + return model, metrics, scaler + + +def _graph_data(lakehouse_dir: Path) -> tuple[Data, list[str]]: + events_glob = str(lakehouse_dir / "bronze" / "synthetic_compliance_events" / "**" / "*.parquet") + edges_path = str(lakehouse_dir / "bronze" / "synthetic_compliance_graph_edges" / "graph_edges.parquet") + query = f""" + WITH nodes AS ( + SELECT organization_id AS organization_id, + AVG(amount_ngn_equivalent) AS avg_amount, + AVG(velocity_24h) AS avg_velocity, + AVG(failed_auth_24h) AS avg_failed_auth, + AVG(CASE WHEN cross_border THEN 1 ELSE 0 END) AS cross_border_ratio, + AVG(simulated_review_label) AS review_ratio + FROM read_parquet('{events_glob}', hive_partitioning=true) + GROUP BY organization_id + ) + SELECT * FROM nodes ORDER BY organization_id + """ + connection = duckdb.connect(":memory:") + node_rows = connection.execute(query).fetchall() + edge_rows = connection.execute( + f"SELECT source_organization_id, target_organization_id FROM read_parquet('{edges_path}')" + ).fetchall() + connection.close() + node_ids = [str(row[0]) for row in node_rows] + index = {node_id: position for position, node_id in enumerate(node_ids)} + raw_features = np.asarray([[float(value) for value in row[1:5]] for row in node_rows], dtype=np.float32) + feature_mean = raw_features.mean(axis=0) + feature_std = np.where(raw_features.std(axis=0) < 1e-6, 1.0, raw_features.std(axis=0)) + labels = np.asarray([float(row[5] >= 0.16) for row in node_rows], dtype=np.float32) + edges: list[tuple[int, int]] = [] + for src, dst in edge_rows: + if src in index and dst in index: + edges.extend([(index[src], index[dst]), (index[dst], index[src])]) + if not edges: + raise ValueError("synthetic graph has no usable edges") + edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous() + data = Data( + x=torch.tensor((raw_features - feature_mean) / feature_std, dtype=torch.float32), + edge_index=edge_index, + y=torch.tensor(labels, dtype=torch.float32), + ) + count = len(node_ids) + data.train_mask = torch.arange(count) < int(count * 0.70) + data.val_mask = (torch.arange(count) >= int(count * 0.70)) & (torch.arange(count) < int(count * 0.85)) + data.test_mask = torch.arange(count) >= int(count * 0.85) + return data, node_ids + + +def train_graph_model(lakehouse_dir: Path, seed: int) -> tuple[OrganizationGraphSAGE, dict[str, Any], list[str]]: + graph, node_ids = _graph_data(lakehouse_dir) + model = OrganizationGraphSAGE(graph.x.shape[1]).to(DEVICE) + graph = graph.to(DEVICE) + positives = max(float(graph.y[graph.train_mask].sum().item()), 1.0) + negatives = max(float(graph.train_mask.sum().item()) - positives, 1.0) + loss_fn = nn.BCEWithLogitsLoss(pos_weight=torch.tensor(negatives / positives, device=DEVICE)) + optimizer = torch.optim.AdamW(model.parameters(), lr=0.01, weight_decay=5e-4) + best_state: dict[str, torch.Tensor] | None = None + best_val = float("inf") + stale = 0 + for epoch in range(120): + model.train() + optimizer.zero_grad() + logits = model(graph.x, graph.edge_index) + loss = loss_fn(logits[graph.train_mask], graph.y[graph.train_mask]) + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + optimizer.step() + model.eval() + with torch.no_grad(): + val_logits = model(graph.x, graph.edge_index)[graph.val_mask] + val_loss = float(loss_fn(val_logits, graph.y[graph.val_mask]).item()) + if val_loss < best_val - 1e-6: + best_val = val_loss + best_state = {name: value.detach().cpu().clone() for name, value in model.state_dict().items()} + stale = 0 + else: + stale += 1 + if stale >= 18: + break + if best_state is None: + raise RuntimeError("graph model did not produce a checkpoint") + model.load_state_dict(best_state) + model.eval() + with torch.no_grad(): + test_logits = model(graph.x, graph.edge_index)[graph.test_mask] + metrics = { + "task": "synthetic_organization_graph_review_classification", + "test": _metrics(test_logits, graph.y[graph.test_mask]), + "validation_bce": round(best_val, 6), + "epochs": epoch + 1, + "nodes": int(graph.num_nodes), + "directed_edges": int(graph.edge_index.shape[1]), + "seed": seed, + } + return model, metrics, node_ids + + +def _load_signing_key(path: Path) -> Ed25519PrivateKey: + private_key = serialization.load_pem_private_key(path.read_bytes(), password=None) + if not isinstance(private_key, Ed25519PrivateKey): + raise ValueError("model signing key must be Ed25519 PEM") + return private_key + + +def _write_signed_manifest( + output_dir: Path, + data_manifest: dict[str, Any], + model_entries: list[dict[str, Any]], + signing_key_path: Path, +) -> Path: + key = _load_signing_key(signing_key_path) + public_bytes = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + manifest = { + "schema_version": MODEL_SCHEMA_VERSION, + "status": "candidate_only", + "classification": "synthetic_training_only", + "created_at": datetime.now(timezone.utc).isoformat(), + "training_dataset_sha256": data_manifest["dataset_sha256"], + "training_dataset_manifest": data_manifest.get("manifest_path", "external"), + "models": model_entries, + "safety_notice": "Model outputs are decision-support candidates. They must not automatically impose penalties, blocks, residency assertions, regulatory findings or adverse actions.", + "signing": {"algorithm": "Ed25519", "public_key_b64": public_bytes.hex()}, + } + signature = key.sign(canonical_json(manifest)) + manifest["signing"]["signature_b64"] = signature.hex() + manifest["manifest_sha256"] = hashlib.sha256(canonical_json(manifest)).hexdigest() + destination = output_dir / "model-manifest.json" + destination.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + return destination + + +def train_all(lakehouse_dir: Path, data_manifest_path: Path, output_dir: Path, signing_key_path: Path, seed: int) -> dict[str, Any]: + set_seed(seed) + data_manifest = _load_manifest(data_manifest_path) + data_manifest["manifest_path"] = str(data_manifest_path.resolve()) + features, labels, _timestamps, _tenants = _event_matrix(lakehouse_dir, data_manifest) + output_dir.mkdir(parents=True, exist_ok=True) + + event_model, event_metrics, scaler = train_event_model(features, labels, seed) + event_path = output_dir / "event_risk_mlp.pt" + torch.save({"state_dict": event_model.state_dict(), "feature_names": FEATURE_NAMES, "scaler": scaler}, event_path) + + graph_model, graph_metrics, node_ids = train_graph_model(lakehouse_dir, seed) + graph_path = output_dir / "organization_graphsage.pt" + torch.save({"state_dict": graph_model.state_dict(), "node_ids": node_ids}, graph_path) + + model_entries = [ + {"name": "event_risk_mlp", "framework": "pytorch", "device": "cpu", "artifact": event_path.name, + "sha256": sha256_path(event_path), "metrics": event_metrics, "input_schema": FEATURE_NAMES}, + {"name": "organization_graphsage", "framework": "pytorch_geometric", "device": "cpu", "artifact": graph_path.name, + "sha256": sha256_path(graph_path), "metrics": graph_metrics, "input_schema": ["organization_graph"]}, + ] + model_manifest = _write_signed_manifest(output_dir, data_manifest, model_entries, signing_key_path) + return {"status": "candidate_trained", "model_manifest": str(model_manifest), "models": model_entries, + "dataset_sha256": data_manifest["dataset_sha256"]} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Train NDSEP synthetic-only CPU candidate models") + parser.add_argument("--lakehouse-dir", required=True, type=Path) + parser.add_argument("--data-manifest", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--signing-key", required=True, type=Path) + parser.add_argument("--seed", default=20260830, type=int) + args = parser.parse_args() + result = train_all(args.lakehouse_dir, args.data_manifest, args.output_dir, args.signing_key, args.seed) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/workers/python/ml_foundation/verify_artifacts.py b/workers/python/ml_foundation/verify_artifacts.py new file mode 100644 index 00000000..be60d4a7 --- /dev/null +++ b/workers/python/ml_foundation/verify_artifacts.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Verify signed NDSEP candidate model artifacts and exercise one CPU inference.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Verify NDSEP signed candidate model artifacts") + parser.add_argument("--model-dir", required=True, type=Path) + args = parser.parse_args() + os.environ["NDSEP_ML_MODEL_DIR"] = str(args.model_dir.resolve()) + import service # Imported after model path is fixed. + + service.MODEL_DIR = args.model_dir.resolve() + service.MANIFEST_PATH = service.MODEL_DIR / "model-manifest.json" + model, scaler, details = service._load_verified_model() + request = service.EventPredictionRequest( + amount_ngn_equivalent=2000.0, + velocity_24h=1, + device_age_days=120, + country_risk_band=0, + cross_border=False, + prior_review_count=0, + failed_auth_24h=0, + unusual_hour=0, + purpose="decision_support", + ) + result = service.predict_event(request) + if result["status"] != "candidate_only" or result["recommended_workflow"] != "human_review_required": + raise SystemExit("candidate model returned an unsafe workflow state") + print(json.dumps({ + "status": "verified", + "device": str(next(model.parameters()).device), + "model": details["entry"]["name"], + "model_sha256": details["entry"]["sha256"], + "dataset_sha256": details["manifest"]["training_dataset_sha256"], + "sample_probability": result["simulated_review_probability"], + "workflow": result["recommended_workflow"], + }, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/workers/rust/Cargo.lock b/workers/rust/Cargo.lock index 604aadd2..f700d409 100644 --- a/workers/rust/Cargo.lock +++ b/workers/rust/Cargo.lock @@ -1453,11 +1453,15 @@ dependencies = [ "axum", "chrono", "lazy_static", + "native-tls", + "postgres-native-tls", "prometheus", "reqwest 0.13.4", "serde", "serde_json", + "sha2 0.10.9", "tokio", + "tokio-postgres", "tracing", "tracing-subscriber", "uuid", @@ -1472,6 +1476,8 @@ dependencies = [ "env_logger", "hex", "log", + "native-tls", + "postgres-native-tls", "rand 0.8.8", "reqwest 0.13.4", "serde", @@ -2173,6 +2179,18 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + [[package]] name = "postgres-protocol" version = "0.6.12" diff --git a/workers/rust/lakehouse_ingest/Cargo.toml b/workers/rust/lakehouse_ingest/Cargo.toml index 2ccd6936..2d12c09d 100644 --- a/workers/rust/lakehouse_ingest/Cargo.toml +++ b/workers/rust/lakehouse_ingest/Cargo.toml @@ -16,3 +16,7 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } prometheus = "0.14" lazy_static = "1" +tokio-postgres = { workspace = true } +sha2 = { workspace = true } +native-tls = "0.2" +postgres-native-tls = "0.5" diff --git a/workers/rust/lakehouse_ingest/src/main.rs b/workers/rust/lakehouse_ingest/src/main.rs index a50e3384..71cd422d 100644 --- a/workers/rust/lakehouse_ingest/src/main.rs +++ b/workers/rust/lakehouse_ingest/src/main.rs @@ -1,46 +1,54 @@ -// NDSEP Lakehouse Ingest Worker — Rust -// Port 8163 | Delta Lake / Apache Iceberg compliance data ingestion -// Ingests compliance events into the NDSEP data lakehouse for analytics and regulatory reporting -// Supports: batch ingest, streaming micro-batch, schema evolution, partitioning by date/sector +//! NDSEP Lakehouse ingest worker. +//! +//! This service persists every accepted record to PostgreSQL before it is made +//! available for downstream Lakehouse delivery. PostgreSQL is the source of +//! truth for ingestion, retry, query, and delivery state; the process keeps no +//! authoritative event queue or business-record cache. use axum::{ extract::State, + http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use chrono::Utc; use lazy_static::lazy_static; +use native_tls::{Certificate, Protocol, TlsConnector}; use prometheus::{Counter, Encoder, Gauge, Registry, TextEncoder}; use serde::{Deserialize, Serialize}; -use std::{ - collections::VecDeque, - env, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, - }, - time::Instant, -}; +use sha2::{Digest, Sha256}; +use std::{collections::HashMap, env, fs, sync::Arc, time::Duration}; +use tokio_postgres::Client; use uuid::Uuid; -fn get_env(key: &str, fallback: &str) -> String { - env::var(key).unwrap_or_else(|_| fallback.to_string()) -} +const CLAIM_LIMIT: i64 = 500; +const MAX_INGEST_RECORDS: usize = 1_000; +const MAX_RECORD_BYTES: usize = 1_000_000; +const DELIVERY_LEASE_SECONDS: i64 = 60; lazy_static! { static ref REGISTRY: Registry = Registry::new(); static ref INGEST_COUNTER: Counter = Counter::new( "ndsep_lakehouse_records_ingested_total", - "Total records ingested" + "Total unique records durably accepted into PostgreSQL" + ) + .expect("valid ingest counter"); + static ref BATCH_COUNTER: Counter = Counter::new( + "ndsep_lakehouse_batches_total", + "Total durable Lakehouse delivery batches claimed" + ) + .expect("valid batch counter"); + static ref ERROR_COUNTER: Counter = Counter::new( + "ndsep_lakehouse_errors_total", + "Total failed Lakehouse ingestion or delivery operations" ) - .unwrap(); - static ref BATCH_COUNTER: Counter = - Counter::new("ndsep_lakehouse_batches_total", "Total batches processed").unwrap(); - static ref ERROR_COUNTER: Counter = - Counter::new("ndsep_lakehouse_errors_total", "Total ingest errors").unwrap(); - static ref QUEUE_GAUGE: Gauge = - Gauge::new("ndsep_lakehouse_queue_depth", "Current ingest queue depth").unwrap(); + .expect("valid error counter"); + static ref QUEUE_GAUGE: Gauge = Gauge::new( + "ndsep_lakehouse_queue_depth", + "Persisted Lakehouse records awaiting delivery" + ) + .expect("valid queue gauge"); } fn init_metrics() { @@ -50,7 +58,6 @@ fn init_metrics() { REGISTRY.register(Box::new(QUEUE_GAUGE.clone())).ok(); } -// NDSEP Lakehouse table definitions const LAKEHOUSE_TABLES: &[(&str, &str, &str)] = &[ ( "compliance_events", @@ -112,51 +119,226 @@ const LAKEHOUSE_TABLES: &[(&str, &str, &str)] = &[ "report_type,period", "Generated regulatory reports", ), + ( + "ml_features", + "feature_group,recorded_at", + "Durable ML feature-store updates", + ), + ( + "ml_predictions", + "model_name,predicted_at", + "Durable ML prediction-log records", + ), + ( + "ml_lineage", + "pipeline_run_id,created_at", + "Durable ML lineage records", + ), ]; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LakehouseRecord { - pub id: String, - pub table: String, - pub partition_key: String, - pub data: serde_json::Value, - pub schema_version: String, - pub ingested_at: i64, - pub source_system: String, +#[derive(Debug, Clone, Serialize)] +struct LakehouseRecord { + id: String, + table: String, + partition_key: String, + data: serde_json::Value, + schema_version: String, + ingested_at: String, + source_system: String, } #[derive(Debug, Deserialize)] -pub struct IngestRequest { - pub table: String, - pub records: Vec, - pub partition_key: Option, - pub schema_version: Option, - pub source_system: Option, +struct IngestRequest { + table: String, + records: Vec, + partition_key: Option, + schema_version: Option, + source_system: Option, } #[derive(Debug, Deserialize)] -pub struct QueryRequest { - pub table: String, - pub filters: Option, - pub limit: Option, - pub offset: Option, - pub partition: Option, +struct QueryRequest { + table: String, + filters: Option, + limit: Option, + offset: Option, + partition: Option, } #[derive(Clone)] -pub struct AppState { - pub buffer: Arc>>, - pub total_ingested: Arc, - pub lakehouse_url: String, - pub s3_bucket: String, - pub start_time: Instant, +struct AppState { + db: Arc, + http: reqwest::Client, + lakehouse_url: String, + start_time: std::time::Instant, +} + +fn configured_database_url() -> Result { + for key in [ + "LAKEHOUSE_DATABASE_URL", + "WORKER_DATABASE_URL", + "DATABASE_URL", + ] { + if let Ok(value) = env::var(key) { + let value = value.trim().to_string(); + if value.starts_with("postgresql://") || value.starts_with("postgres://") { + return Ok(value); + } + return Err(format!("{key} must be a PostgreSQL connection URL")); + } + } + Err("LAKEHOUSE_DATABASE_URL, WORKER_DATABASE_URL, or DATABASE_URL is required; Lakehouse ingestion has no in-memory fallback".to_string()) +} + +fn required_lakehouse_url() -> Result { + let value = env::var("LAKEHOUSE_URL") + .map_err(|_| { + "LAKEHOUSE_URL is required; durable records cannot be silently dropped".to_string() + })? + .trim() + .trim_end_matches('/') + .to_string(); + if !(value.starts_with("https://") + || (env::var("NODE_ENV").unwrap_or_default() != "production" + && value.starts_with("http://"))) + { + return Err( + "LAKEHOUSE_URL must use HTTPS in production and be an absolute HTTP(S) URL".to_string(), + ); + } + Ok(value) +} + +fn configured_port() -> String { + env::var("LAKEHOUSE_INGEST_PORT").unwrap_or_else(|_| "8163".to_string()) +} + +fn is_production() -> bool { + env::var("NODE_ENV") + .map(|value| value.eq_ignore_ascii_case("production")) + .unwrap_or(false) +} + +fn verified_tls_connector() -> Result { + let ca_file = env::var("POSTGRES_TLS_CA_FILE").map_err(|_| { + "POSTGRES_TLS_CA_FILE is required in production for verified PostgreSQL TLS".to_string() + })?; + let ca_pem = + fs::read(&ca_file).map_err(|error| format!("cannot read POSTGRES_TLS_CA_FILE: {error}"))?; + let certificate = Certificate::from_pem(&ca_pem) + .map_err(|error| format!("POSTGRES_TLS_CA_FILE is not a valid PEM certificate: {error}"))?; + let mut builder = TlsConnector::builder(); + builder.add_root_certificate(certificate); + builder.min_protocol_version(Some(Protocol::Tlsv12)); + let connector = builder + .build() + .map_err(|error| format!("cannot build PostgreSQL TLS connector: {error}"))?; + Ok(postgres_native_tls::MakeTlsConnector::new(connector)) +} + +async fn connect_database(database_url: &str) -> Result { + let config: tokio_postgres::Config = database_url + .parse() + .map_err(|error| format!("invalid PostgreSQL connection URL: {error}"))?; + if is_production() { + let tls = verified_tls_connector()?; + let (client, connection) = config + .connect(tls) + .await + .map_err(|error| format!("verified PostgreSQL TLS connection failed: {error}"))?; + tokio::spawn(async move { + if let Err(error) = connection.await { + tracing::error!("Lakehouse PostgreSQL TLS connection terminated: {error}"); + } + }); + return Ok(client); + } + + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .map_err(|error| format!("Lakehouse PostgreSQL connection failed: {error}"))?; + tokio::spawn(async move { + if let Err(error) = connection.await { + tracing::error!("Lakehouse PostgreSQL connection terminated: {error}"); + } + }); + Ok(client) +} + +fn allowed_table(table: &str) -> bool { + LAKEHOUSE_TABLES.iter().any(|(name, _, _)| *name == table) +} + +fn is_safe_label(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && !value.contains("..") + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'=' | b'/') + }) +} + +fn canonical_record_hash( + table: &str, + partition_key: &str, + schema_version: &str, + source_system: &str, + data: &serde_json::Value, +) -> Result { + let encoded = serde_json::to_vec(data) + .map_err(|error| format!("record serialization failed: {error}"))?; + if encoded.len() > MAX_RECORD_BYTES { + return Err(format!("record exceeds {MAX_RECORD_BYTES} byte limit")); + } + let mut hash = Sha256::new(); + for value in [ + table.as_bytes(), + partition_key.as_bytes(), + schema_version.as_bytes(), + source_system.as_bytes(), + &encoded, + ] { + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value); + } + Ok(format!("{:x}", hash.finalize())) +} + +fn error_response( + status: StatusCode, + message: impl Into, +) -> (StatusCode, Json) { + ( + status, + Json(serde_json::json!({"success": false, "error": message.into()})), + ) +} + +async fn pending_depth(db: &Client) -> Result { + let row = db + .query_one( + "SELECT COUNT(*) FROM lakehouse_ingest_records WHERE delivery_status IN ('pending', 'retry') AND next_attempt_at <= NOW()", + &[], + ) + .await?; + Ok(row.get(0)) } async fn ingest_records( State(state): State, Json(req): Json, ) -> impl IntoResponse { - let now = Utc::now().timestamp_millis(); + if !allowed_table(&req.table) { + return error_response(StatusCode::BAD_REQUEST, "unsupported Lakehouse table"); + } + if req.records.is_empty() || req.records.len() > MAX_INGEST_RECORDS { + return error_response( + StatusCode::BAD_REQUEST, + format!("records must contain between 1 and {MAX_INGEST_RECORDS} values"), + ); + } + let partition_key = req .partition_key .unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()); @@ -164,166 +346,387 @@ async fn ingest_records( let source_system = req .source_system .unwrap_or_else(|| "ndsep-platform".to_string()); + if !is_safe_label(&partition_key, 128) + || !is_safe_label(&schema_version, 64) + || !is_safe_label(&source_system, 128) + { + return error_response( + StatusCode::BAD_REQUEST, + "partition_key, schema_version, or source_system is invalid", + ); + } - let count = req.records.len(); - let mut buffer = state.buffer.lock().unwrap(); - - for record_data in req.records { - let record = LakehouseRecord { - id: Uuid::new_v4().to_string(), - table: req.table.clone(), - partition_key: partition_key.clone(), - data: record_data, - schema_version: schema_version.clone(), - ingested_at: now, - source_system: source_system.clone(), + let mut accepted = 0_u64; + let mut duplicates = 0_u64; + for data in req.records { + let record_hash = match canonical_record_hash( + &req.table, + &partition_key, + &schema_version, + &source_system, + &data, + ) { + Ok(hash) => hash, + Err(error) => return error_response(StatusCode::BAD_REQUEST, error), }; - buffer.push_back(record); + let data_json = data.to_string(); + let id = Uuid::new_v4().to_string(); + match state + .db + .execute( + "INSERT INTO lakehouse_ingest_records (id, table_name, partition_key, data, schema_version, source_system, record_hash, delivery_status, next_attempt_at) + VALUES ($1::text::uuid, $2, $3, $4::text::jsonb, $5, $6, $7, 'pending', NOW()) + ON CONFLICT (record_hash) DO NOTHING", + &[&id, &req.table, &partition_key, &data_json, &schema_version, &source_system, &record_hash], + ) + .await + { + Ok(1) => accepted += 1, + Ok(_) => duplicates += 1, + Err(error) => { + ERROR_COUNTER.inc(); + return error_response(StatusCode::SERVICE_UNAVAILABLE, format!("durable Lakehouse insert failed: {error}")); + } + } } - INGEST_COUNTER.inc_by(count as f64); - state - .total_ingested - .fetch_add(count as u64, Ordering::Relaxed); - QUEUE_GAUGE.set(buffer.len() as f64); + INGEST_COUNTER.inc_by(accepted as f64); + match pending_depth(&state.db).await { + Ok(depth) => QUEUE_GAUGE.set(depth as f64), + Err(_) => ERROR_COUNTER.inc(), + } + ( + StatusCode::ACCEPTED, + Json(serde_json::json!({ + "success": true, + "table": req.table, + "accepted": accepted, + "duplicates": duplicates, + "partitionKey": partition_key, + "schemaVersion": schema_version, + "timestamp": Utc::now().to_rfc3339(), + })), + ) +} - Json(serde_json::json!({ - "success": true, - "table": req.table, - "ingested": count, - "partitionKey": partition_key, - "schemaVersion": schema_version, - "timestamp": now, - })) +async fn claim_records(db: &Client) -> Result, tokio_postgres::Error> { + let rows = db + .query( + "WITH claimable AS ( + SELECT id FROM lakehouse_ingest_records + WHERE delivery_status IN ('pending', 'retry') AND next_attempt_at <= NOW() + ORDER BY created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT $1 + ), claimed AS ( + UPDATE lakehouse_ingest_records record + SET delivery_status = 'sending', attempts = attempts + 1, + leased_at = NOW(), lease_expires_at = NOW() + ($2::text || ' seconds')::interval, + updated_at = NOW() + FROM claimable + WHERE record.id = claimable.id + RETURNING record.id::text, record.table_name, record.partition_key, record.data, + record.schema_version, record.created_at::text, record.source_system + ) SELECT * FROM claimed", + &[&CLAIM_LIMIT, &DELIVERY_LEASE_SECONDS.to_string()], + ) + .await?; + Ok(rows + .iter() + .map(|row| LakehouseRecord { + id: row.get(0), + table: row.get(1), + partition_key: row.get(2), + data: row.get(3), + schema_version: row.get(4), + ingested_at: row.get(5), + source_system: row.get(6), + }) + .collect()) +} + +async fn mark_delivery_success( + db: &Client, + records: &[LakehouseRecord], +) -> Result<(), tokio_postgres::Error> { + for record in records { + db.execute( + "UPDATE lakehouse_ingest_records + SET delivery_status = 'delivered', delivered_at = NOW(), leased_at = NULL, + lease_expires_at = NULL, last_error = NULL, updated_at = NOW() + WHERE id = $1::text::uuid AND delivery_status = 'sending'", + &[&record.id], + ) + .await?; + } + Ok(()) } -async fn flush_buffer(State(state): State) -> impl IntoResponse { - // Drain buffer while holding lock, then release before async HTTP calls - let (count, records) = { - let mut buffer = state.buffer.lock().unwrap(); - let count = buffer.len(); - let records: Vec = buffer.drain(..).collect(); - QUEUE_GAUGE.set(0.0); - BATCH_COUNTER.inc(); - (count, records) +async fn reschedule_delivery( + db: &Client, + records: &[LakehouseRecord], + error: &str, +) -> Result<(), tokio_postgres::Error> { + let sanitized = error.chars().take(512).collect::(); + for record in records { + db.execute( + "UPDATE lakehouse_ingest_records + SET delivery_status = 'retry', leased_at = NULL, lease_expires_at = NULL, + last_error = $2, + next_attempt_at = NOW() + make_interval(secs => LEAST(900, GREATEST(30, attempts * 30))), + updated_at = NOW() + WHERE id = $1::text::uuid AND delivery_status = 'sending'", + &[&record.id, &sanitized], + ) + .await?; + } + Ok(()) +} + +async fn recover_expired_leases(db: &Client) -> Result { + db.execute( + "UPDATE lakehouse_ingest_records + SET delivery_status = 'retry', leased_at = NULL, lease_expires_at = NULL, + next_attempt_at = NOW(), last_error = COALESCE(last_error, 'delivery lease expired'), updated_at = NOW() + WHERE delivery_status = 'sending' AND lease_expires_at < NOW()", + &[], + ) + .await +} + +async fn flush_buffer(State(state): State) -> axum::response::Response { + if let Err(error) = recover_expired_leases(&state.db).await { + ERROR_COUNTER.inc(); + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + format!("lease recovery failed: {error}"), + ) + .into_response(); + } + let records = match claim_records(&state.db).await { + Ok(records) => records, + Err(error) => { + ERROR_COUNTER.inc(); + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + format!("durable Lakehouse claim failed: {error}"), + ) + .into_response(); + } }; + if records.is_empty() { + return ( + StatusCode::OK, + Json(serde_json::json!({"success": true, "flushed": 0, "tables": []})), + ) + .into_response(); + } - // Group by table for batch write to lakehouse analytics engine - let mut by_table: std::collections::HashMap> = - std::collections::HashMap::new(); - for r in &records { - by_table - .entry(r.table.clone()) + BATCH_COUNTER.inc(); + let mut grouped: HashMap> = HashMap::new(); + for record in records { + grouped + .entry(record.table.clone()) .or_default() - .push(r.data.clone()); + .push(record); } - let lakehouse_url = state.lakehouse_url.clone(); - let mut table_summary: Vec = Vec::new(); - let client = reqwest::Client::new(); - - for (table, data_records) in &by_table { + let mut succeeded = 0_usize; + let mut failed = 0_usize; + let mut table_summary = Vec::new(); + for (table, table_records) in grouped { let payload = serde_json::json!({ "namespace": "ndsep", "table": table, - "records": data_records, + "records": table_records.iter().map(|record| &record.data).collect::>(), }); - let write_result = client - .post(format!("{}/ingest", lakehouse_url)) + let result = state + .http + .post(format!("{}/ingest", state.lakehouse_url)) .json(&payload) - .timeout(std::time::Duration::from_secs(10)) + .timeout(Duration::from_secs(10)) .send() .await; - let status = match write_result { - Ok(resp) => { - if resp.status().is_success() { - "written" - } else { - "http_error" + let (status, outcome) = match result { + Ok(response) if response.status().is_success() => { + match mark_delivery_success(&state.db, &table_records).await { + Ok(()) => { + succeeded += table_records.len(); + ("delivered", None) + } + Err(error) => { + ERROR_COUNTER.inc(); + let detail = + format!("delivery acknowledgement could not be persisted: {error}"); + let _ = reschedule_delivery(&state.db, &table_records, &detail).await; + failed += table_records.len(); + ("persistence_failed", Some(detail)) + } } } - Err(_) => "connection_failed", + Ok(response) => { + let detail = format!("Lakehouse endpoint returned HTTP {}", response.status()); + let _ = reschedule_delivery(&state.db, &table_records, &detail).await; + failed += table_records.len(); + ("retry_scheduled", Some(detail)) + } + Err(error) => { + let detail = format!("Lakehouse endpoint request failed: {error}"); + let _ = reschedule_delivery(&state.db, &table_records, &detail).await; + failed += table_records.len(); + ("retry_scheduled", Some(detail)) + } }; table_summary.push(serde_json::json!({ "table": table, - "records": data_records.len(), - "lakehouse_status": status, + "records": table_records.len(), + "delivery_status": status, + "error": outcome, })); } - Json(serde_json::json!({ - "success": true, - "flushed": count, - "tables": table_summary, - "lakehouse_forwarded": true, - "timestamp": Utc::now().timestamp_millis(), - })) + let depth = pending_depth(&state.db).await.unwrap_or(-1); + if depth >= 0 { + QUEUE_GAUGE.set(depth as f64); + } + if failed > 0 { + ERROR_COUNTER.inc_by(failed as f64); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "success": false, + "delivered": succeeded, + "retry_scheduled": failed, + "tables": table_summary, + "timestamp": Utc::now().to_rfc3339(), + })), + ) + .into_response(); + } + ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "delivered": succeeded, + "tables": table_summary, + "timestamp": Utc::now().to_rfc3339(), + })), + ) + .into_response() } async fn query_table( State(state): State, Json(req): Json, ) -> impl IntoResponse { - let buffer = state.buffer.lock().unwrap(); - let limit = req.limit.unwrap_or(20) as usize; - let offset = req.offset.unwrap_or(0) as usize; + if !allowed_table(&req.table) { + return error_response(StatusCode::BAD_REQUEST, "unsupported Lakehouse table") + .into_response(); + } + let limit = req.limit.unwrap_or(20).clamp(1, 500) as i64; + let offset = req.offset.unwrap_or(0).min(100_000) as i64; + if let Some(partition) = &req.partition { + if !is_safe_label(partition, 128) { + return error_response(StatusCode::BAD_REQUEST, "partition is invalid").into_response(); + } + } + let filters_json = match req.filters { + Some(filters) if filters.is_object() => filters.to_string(), + Some(_) => { + return error_response(StatusCode::BAD_REQUEST, "filters must be a JSON object") + .into_response() + } + None => "{}".to_string(), + }; + let partition = req.partition.unwrap_or_default(); - let results: Vec<&LakehouseRecord> = buffer + let rows = match state + .db + .query( + "SELECT id::text, table_name, partition_key, data, schema_version, created_at::text, source_system + FROM lakehouse_ingest_records + WHERE table_name = $1 + AND ($2 = '' OR partition_key LIKE $2 || '%') + AND ($3::text::jsonb = '{}'::jsonb OR data @> $3::text::jsonb) + ORDER BY created_at DESC, id DESC LIMIT $4 OFFSET $5", + &[&req.table, &partition, &filters_json, &limit, &offset], + ) + .await + { + Ok(rows) => rows, + Err(error) => { + ERROR_COUNTER.inc(); + return error_response(StatusCode::SERVICE_UNAVAILABLE, format!("durable Lakehouse query failed: {error}")).into_response(); + } + }; + let records = rows .iter() - .filter(|r| r.table == req.table) - .filter(|r| { - if let Some(partition) = &req.partition { - r.partition_key.starts_with(partition.as_str()) - } else { - true - } + .map(|row| LakehouseRecord { + id: row.get(0), + table: row.get(1), + partition_key: row.get(2), + data: row.get(3), + schema_version: row.get(4), + ingested_at: row.get(5), + source_system: row.get(6), }) - .skip(offset) - .take(limit) - .collect(); - - Json(serde_json::json!({ - "success": true, - "table": req.table, - "records": results, - "count": results.len(), - "total_in_buffer": buffer.iter().filter(|r| r.table == req.table).count(), - })) + .collect::>(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "table": req.table, + "records": records, + "count": records.len(), + "limit": limit, + "offset": offset, + })), + ) + .into_response() } -async fn list_tables() -> impl IntoResponse { +async fn list_tables() -> Json { Json(serde_json::json!({ - "tables": LAKEHOUSE_TABLES.iter().map(|(name, partition, desc)| { - serde_json::json!({ - "name": name, - "partitionBy": partition, - "description": desc, - }) - }).collect::>(), + "tables": LAKEHOUSE_TABLES.iter().map(|(name, partition, description)| serde_json::json!({ + "name": name, + "partitionBy": partition, + "description": description, + })).collect::>(), "count": LAKEHOUSE_TABLES.len(), })) } async fn health(State(state): State) -> impl IntoResponse { - let buffer = state.buffer.lock().unwrap(); - Json(serde_json::json!({ - "status": "healthy", - "service": "ndsep-lakehouse-ingest", - "version": "1.0.0", - "uptime": state.start_time.elapsed().as_secs(), - "total_ingested": state.total_ingested.load(Ordering::Relaxed), - "queue_depth": buffer.len(), - "tables": LAKEHOUSE_TABLES.len(), - "lakehouse_url": state.lakehouse_url, - "s3_bucket": state.s3_bucket, - })) + match pending_depth(&state.db).await { + Ok(depth) => { + QUEUE_GAUGE.set(depth as f64); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "healthy", + "service": "ndsep-lakehouse-ingest", + "uptime": state.start_time.elapsed().as_secs(), + "persisted_queue_depth": depth, + "tables": LAKEHOUSE_TABLES.len(), + })), + ) + .into_response() + } + Err(error) => error_response( + StatusCode::SERVICE_UNAVAILABLE, + format!("PostgreSQL health check failed: {error}"), + ) + .into_response(), + } } async fn metrics() -> impl IntoResponse { let encoder = TextEncoder::new(); let metric_families = REGISTRY.gather(); let mut buffer = Vec::new(); - encoder.encode(&metric_families, &mut buffer).unwrap(); + if encoder.encode(&metric_families, &mut buffer).is_err() { + return (StatusCode::INTERNAL_SERVER_ERROR, "metric encoding failed").into_response(); + } ( [( axum::http::header::CONTENT_TYPE, @@ -331,6 +734,7 @@ async fn metrics() -> impl IntoResponse { )], String::from_utf8(buffer).unwrap_or_default(), ) + .into_response() } #[tokio::main] @@ -338,18 +742,19 @@ async fn main() { tracing_subscriber::fmt::init(); init_metrics(); - let port = get_env("LAKEHOUSE_INGEST_PORT", "8163"); - let lakehouse_url = get_env("LAKEHOUSE_URL", "http://localhost:8080"); - let s3_bucket = get_env("LAKEHOUSE_S3_BUCKET", "ndsep-lakehouse"); + let database_url = configured_database_url().unwrap_or_else(|error| panic!("{error}")); + let lakehouse_url = required_lakehouse_url().unwrap_or_else(|error| panic!("{error}")); + let db = connect_database(&database_url) + .await + .unwrap_or_else(|error| panic!("Lakehouse PostgreSQL connection failed: {error}")); let state = AppState { - buffer: Arc::new(Mutex::new(VecDeque::new())), - total_ingested: Arc::new(AtomicU64::new(0)), - lakehouse_url: lakehouse_url.clone(), - s3_bucket: s3_bucket.clone(), - start_time: Instant::now(), + db: Arc::new(db), + http: reqwest::Client::new(), + lakehouse_url, + start_time: std::time::Instant::now(), }; - + let port = configured_port(); let app = Router::new() .route("/ingest", post(ingest_records)) .route("/flush", post(flush_buffer)) @@ -359,14 +764,148 @@ async fn main() { .route("/metrics", get(metrics)) .with_state(state); - let addr = format!("0.0.0.0:{}", port); - tracing::info!("NDSEP Lakehouse Ingest Worker starting on {}", addr); - tracing::info!( - "Lakehouse URL: {} | S3 Bucket: {}", - lakehouse_url, - s3_bucket - ); + let address = format!("0.0.0.0:{port}"); + tracing::info!("NDSEP Lakehouse ingest worker starting on {address}"); + let listener = tokio::net::TcpListener::bind(&address) + .await + .unwrap_or_else(|error| panic!("Lakehouse ingest bind failed: {error}")); + axum::serve(listener, app) + .await + .unwrap_or_else(|error| panic!("Lakehouse ingest server failed: {error}")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_unknown_tables_and_invalid_labels() { + assert!(!allowed_table("unknown_table")); + assert!(!is_safe_label("../escape", 128)); + assert!(is_safe_label("2026-09-01", 128)); + } + + #[test] + fn canonical_hash_is_stable_and_domain_bound() { + let event = serde_json::json!({"event": "compliance.created", "id": "evt-1"}); + let first = + canonical_record_hash("compliance_events", "2026-09-01", "1.0", "ndsep", &event) + .unwrap(); + let second = + canonical_record_hash("compliance_events", "2026-09-01", "1.0", "ndsep", &event) + .unwrap(); + let different_table = + canonical_record_hash("audit_trail", "2026-09-01", "1.0", "ndsep", &event).unwrap(); + assert_eq!(first, second); + assert_ne!(first, different_table); + } - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + #[tokio::test] + async fn postgres_ledger_is_idempotent_and_claimable_when_explicitly_configured() { + let url = match env::var("LAKEHOUSE_INGEST_TEST_DATABASE_URL") { + Ok(url) + if url + .rsplit_once('@') + .map(|(_, authority)| { + authority.starts_with("127.0.0.1:") || authority.starts_with("localhost:") + }) + .unwrap_or(false) => + { + url + } + Ok(_) => panic!( + "LAKEHOUSE_INGEST_TEST_DATABASE_URL must target disposable localhost PostgreSQL" + ), + Err(_) => return, + }; + let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls) + .await + .expect("connect disposable Lakehouse test database"); + tokio::spawn(async move { + let _ = connection.await; + }); + client + .batch_execute(include_str!( + "../../../../drizzle/0039_lakehouse_durable_postgres_storage.sql" + )) + .await + .expect("apply active Lakehouse migration"); + + let source_system = "rust-ci-lakehouse-test"; + client + .execute( + "DELETE FROM lakehouse_ingest_records WHERE source_system = $1", + &[&source_system], + ) + .await + .expect("clear prior test rows"); + let data = + serde_json::json!({"test_id": Uuid::new_v4().to_string(), "event": "durability"}); + let data_json = data.to_string(); + let record_hash = canonical_record_hash( + "compliance_events", + "2026-09-01", + "1.0", + source_system, + &data, + ) + .unwrap(); + let record_id = Uuid::new_v4().to_string(); + let insert = "INSERT INTO lakehouse_ingest_records (id, table_name, partition_key, data, schema_version, source_system, record_hash, delivery_status, next_attempt_at) + VALUES ($1::text::uuid, 'compliance_events', '2026-09-01', $2::text::jsonb, '1.0', $3, $4, 'pending', NOW()) + ON CONFLICT (record_hash) DO NOTHING"; + assert_eq!( + client + .execute( + insert, + &[&record_id, &data_json, &source_system, &record_hash] + ) + .await + .unwrap(), + 1 + ); + assert_eq!( + client + .execute( + insert, + &[ + &Uuid::new_v4().to_string(), + &data_json, + &source_system, + &record_hash + ] + ) + .await + .unwrap(), + 0 + ); + + let records = claim_records(&client) + .await + .expect("claim persisted records"); + let claimed = records + .iter() + .find(|record| record.id == record_id) + .expect("test record is claimed"); + assert_eq!(claimed.data, data); + mark_delivery_success(&client, std::slice::from_ref(claimed)) + .await + .expect("persist acknowledgement"); + let status: String = client + .query_one( + "SELECT delivery_status FROM lakehouse_ingest_records WHERE id = $1::text::uuid", + &[&record_id], + ) + .await + .expect("read persisted delivery state") + .get(0); + assert_eq!(status, "delivered"); + client + .execute( + "DELETE FROM lakehouse_ingest_records WHERE source_system = $1", + &[&source_system], + ) + .await + .expect("clean test rows"); + } } diff --git a/workers/rust/lakehouse_writer/Cargo.toml b/workers/rust/lakehouse_writer/Cargo.toml index 2a3e6ec8..03cd468e 100644 --- a/workers/rust/lakehouse_writer/Cargo.toml +++ b/workers/rust/lakehouse_writer/Cargo.toml @@ -17,3 +17,5 @@ sha2 = { workspace = true } hex = { workspace = true } tokio-postgres = { workspace = true } reqwest = { workspace = true } +native-tls = "0.2" +postgres-native-tls = "0.5" diff --git a/workers/rust/lakehouse_writer/src/main.rs b/workers/rust/lakehouse_writer/src/main.rs index e8ce5168..d2cd370c 100644 --- a/workers/rust/lakehouse_writer/src/main.rs +++ b/workers/rust/lakehouse_writer/src/main.rs @@ -1,111 +1,118 @@ -// NDSEP Lakehouse Feature Store Writer (Rust) -// ============================================= -// High-throughput writer for the NDSEP data lakehouse. -// Writes ML features, model predictions, and audit lineage to: -// - PostgreSQL feature tables (online store) -// - Delta Lake / Iceberg-compatible Parquet files (offline store) -// - Model prediction log (for drift detection) -// - Feature lineage graph (for auditability) -// -// Feature groups written: -// - compliance_features : org compliance metrics (hourly) -// - risk_features : sector risk scores (daily) -// - behavioral_features : login/API usage patterns (hourly) -// - prediction_log : model inference results (real-time) -// - drift_metrics : feature distribution drift (daily) -// -// Technology: Rust · Axum · Tokio · tokio-postgres -// Port: 8215 +//! NDSEP Lakehouse Feature Store Writer. +//! +//! PostgreSQL is the source of truth for online ML feature, prediction, and lineage +//! records. Each successful write is committed in the same transaction as a durable +//! Lakehouse delivery-ledger record. The separate lakehouse_ingest worker claims that +//! ledger and delivers it downstream; this process has no in-memory write queue and +//! does not report a successful write when either durable operation fails. use axum::{ extract::State, + http::StatusCode, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; -use chrono::Utc; +use chrono::{DateTime, Utc}; +use native_tls::{Certificate, Protocol, TlsConnector}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::{ collections::HashMap, - env, + env, fs, sync::{ atomic::{AtomicU64, Ordering}, Arc, }, time::Instant, }; -use tokio_postgres::NoTls; +use tokio_postgres::{Client, NoTls, Transaction}; use uuid::Uuid; +const MAX_LABEL_LENGTH: usize = 256; +const MAX_JSON_BYTES: usize = 1_000_000; +const OUTBOX_SCHEMA_VERSION: &str = "1.0"; +const OUTBOX_SOURCE_SYSTEM: &str = "lakehouse-writer"; + // ── Configuration ────────────────────────────────────────────────────────────── -fn get_env(key: &str, default: &str) -> String { - env::var(key).unwrap_or_else(|_| default.to_string()) + +fn is_production() -> bool { + env::var("NODE_ENV") + .map(|value| value.eq_ignore_ascii_case("production")) + .unwrap_or(false) +} + +fn required_postgres_url() -> Result { + for key in [ + "LAKEHOUSE_DATABASE_URL", + "WORKER_DATABASE_URL", + "DATABASE_URL", + ] { + if let Ok(value) = env::var(key) { + let value = value.trim().to_string(); + if value.starts_with("postgresql://") || value.starts_with("postgres://") { + return Ok(value); + } + return Err(format!("{key} must be a PostgreSQL connection URL")); + } + } + Err("LAKEHOUSE_DATABASE_URL, WORKER_DATABASE_URL, or DATABASE_URL is required; Lakehouse writer has no database fallback".to_string()) +} + +fn configured_port() -> String { + env::var("LAKEHOUSE_WRITER_PORT") + .or_else(|_| env::var("PORT")) + .unwrap_or_else(|_| "8215".to_string()) +} + +fn verified_tls_connector() -> Result { + let ca_file = env::var("POSTGRES_TLS_CA_FILE").map_err(|_| { + "POSTGRES_TLS_CA_FILE is required in production for verified PostgreSQL TLS".to_string() + })?; + let ca_pem = + fs::read(&ca_file).map_err(|error| format!("cannot read POSTGRES_TLS_CA_FILE: {error}"))?; + let certificate = Certificate::from_pem(&ca_pem) + .map_err(|error| format!("POSTGRES_TLS_CA_FILE is not a valid PEM certificate: {error}"))?; + let mut builder = TlsConnector::builder(); + builder.add_root_certificate(certificate); + builder.min_protocol_version(Some(Protocol::Tlsv12)); + let connector = builder + .build() + .map_err(|error| format!("cannot build PostgreSQL TLS connector: {error}"))?; + Ok(postgres_native_tls::MakeTlsConnector::new(connector)) } // ── State ────────────────────────────────────────────────────────────────────── + #[derive(Clone)] struct AppState { db_url: String, - lakehouse_analytics_url: String, start_time: Instant, writes_total: Arc, writes_failed: Arc, feature_writes: Arc, prediction_writes: Arc, lineage_writes: Arc, - parquet_forwards: Arc, + lakehouse_enqueues: Arc, } impl AppState { - fn new(db_url: String, lakehouse_analytics_url: String) -> Self { - AppState { + fn new(db_url: String) -> Self { + Self { db_url, - lakehouse_analytics_url, start_time: Instant::now(), writes_total: Arc::new(AtomicU64::new(0)), writes_failed: Arc::new(AtomicU64::new(0)), feature_writes: Arc::new(AtomicU64::new(0)), prediction_writes: Arc::new(AtomicU64::new(0)), lineage_writes: Arc::new(AtomicU64::new(0)), - parquet_forwards: Arc::new(AtomicU64::new(0)), + lakehouse_enqueues: Arc::new(AtomicU64::new(0)), } } } -/// Forward a record to the Lakehouse Analytics Engine for Parquet (offline) storage. -async fn forward_to_parquet(state: &AppState, table: &str, record: &serde_json::Value) { - if state.lakehouse_analytics_url.is_empty() { - return; - } - let client = reqwest::Client::new(); - let payload = serde_json::json!({ - "namespace": "ndsep", - "table": table, - "records": [record], - }); - match client - .post(format!("{}/ingest", state.lakehouse_analytics_url)) - .json(&payload) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - state.parquet_forwards.fetch_add(1, Ordering::Relaxed); - log::debug!("[Lakehouse] Forwarded to Parquet offline store: {table}"); - } - Ok(resp) => { - log::debug!( - "[Lakehouse] Parquet forward to {table}: HTTP {}", - resp.status() - ); - } - Err(e) => { - log::debug!("[Lakehouse] Parquet forward unavailable: {e}"); - } - } -} +// ── Request and response types ───────────────────────────────────────────────── -// ── Request types ────────────────────────────────────────────────────────────── #[derive(Deserialize)] struct FeatureWriteRequest { feature_group: String, @@ -152,295 +159,577 @@ struct HealthResponse { feature_writes: u64, prediction_writes: u64, lineage_writes: u64, + lakehouse_enqueues: u64, uptime_seconds: f64, } -// ── Database helpers ─────────────────────────────────────────────────────────── -async fn get_db_client(db_url: &str) -> Result { - let (client, connection) = tokio_postgres::connect(db_url, NoTls).await?; +#[derive(Debug)] +enum WriteFailure { + BadRequest(String), + Unavailable(String), +} + +// ── Input and database helpers ───────────────────────────────────────────────── + +fn valid_label(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && value == value.trim() + && !value.contains("..") + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/') + }) +} + +fn checked_json(value: &serde_json::Value, field: &str) -> Result { + let encoded = serde_json::to_vec(value).map_err(|error| { + WriteFailure::BadRequest(format!("{field} cannot be serialized as JSON: {error}")) + })?; + if encoded.len() > MAX_JSON_BYTES { + return Err(WriteFailure::BadRequest(format!( + "{field} exceeds the {MAX_JSON_BYTES}-byte limit" + ))); + } + String::from_utf8(encoded) + .map_err(|error| WriteFailure::BadRequest(format!("{field} JSON is not UTF-8: {error}"))) +} + +fn normalized_timestamp(timestamp: Option) -> Result { + match timestamp { + Some(value) => DateTime::parse_from_rfc3339(&value) + .map(|parsed| parsed.with_timezone(&Utc).to_rfc3339()) + .map_err(|_| WriteFailure::BadRequest("timestamp must be RFC 3339".to_string())), + None => Ok(Utc::now().to_rfc3339()), + } +} + +fn canonical_outbox_hash( + table: &str, + partition_key: &str, + data: &serde_json::Value, +) -> Result { + let encoded = serde_json::to_vec(data).map_err(|error| { + WriteFailure::BadRequest(format!( + "Lakehouse outbox record cannot be serialized: {error}" + )) + })?; + if encoded.len() > MAX_JSON_BYTES { + return Err(WriteFailure::BadRequest(format!( + "Lakehouse outbox record exceeds the {MAX_JSON_BYTES}-byte limit" + ))); + } + let mut hash = Sha256::new(); + for value in [ + table.as_bytes(), + partition_key.as_bytes(), + OUTBOX_SCHEMA_VERSION.as_bytes(), + OUTBOX_SOURCE_SYSTEM.as_bytes(), + &encoded, + ] { + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value); + } + Ok(format!("{:x}", hash.finalize())) +} + +async fn get_db_client(db_url: &str) -> Result { + let config: tokio_postgres::Config = db_url + .parse() + .map_err(|error| format!("invalid PostgreSQL connection URL: {error}"))?; + if is_production() { + let tls = verified_tls_connector()?; + let (client, connection) = config + .connect(tls) + .await + .map_err(|error| format!("verified PostgreSQL TLS connection failed: {error}"))?; + tokio::spawn(async move { + if let Err(error) = connection.await { + log::error!("[Lakehouse] PostgreSQL TLS connection error: {error}"); + } + }); + return Ok(client); + } + + let (client, connection) = config + .connect(NoTls) + .await + .map_err(|error| format!("PostgreSQL connection failed: {error}"))?; tokio::spawn(async move { - if let Err(e) = connection.await { - log::error!("[Lakehouse] DB connection error: {}", e); + if let Err(error) = connection.await { + log::error!("[Lakehouse] PostgreSQL connection error: {error}"); } }); Ok(client) } -// ── Handlers ─────────────────────────────────────────────────────────────────── -async fn health(State(state): State) -> Json { - Json(HealthResponse { - status: "healthy".to_string(), - worker: "lakehouse_writer".to_string(), - writes_total: state.writes_total.load(Ordering::Relaxed), - writes_failed: state.writes_failed.load(Ordering::Relaxed), - feature_writes: state.feature_writes.load(Ordering::Relaxed), - prediction_writes: state.prediction_writes.load(Ordering::Relaxed), - lineage_writes: state.lineage_writes.load(Ordering::Relaxed), - uptime_seconds: state.start_time.elapsed().as_secs_f64(), - }) +async fn ensure_migration_tables(client: &Client) -> Result<(), String> { + let row = client + .query_one( + "SELECT to_regclass('public.lakehouse_ingest_records') IS NOT NULL + AND to_regclass('public.ml_feature_store') IS NOT NULL + AND to_regclass('public.ml_prediction_log') IS NOT NULL + AND to_regclass('public.ml_lineage') IS NOT NULL", + &[], + ) + .await + .map_err(|error| format!("cannot verify migration-owned Lakehouse tables: {error}"))?; + if row.get::<_, bool>(0) { + Ok(()) + } else { + Err( + "Lakehouse migration 0039 has not been applied; refusing to use runtime DDL" + .to_string(), + ) + } } -async fn write_features( - State(state): State, - Json(req): Json, -) -> Json { - let record_id = Uuid::new_v4().to_string(); - let ts = req - .timestamp - .clone() - .unwrap_or_else(|| Utc::now().to_rfc3339()); - state.writes_total.fetch_add(1, Ordering::Relaxed); +async fn enqueue_lakehouse_delivery( + transaction: &Transaction<'_>, + table: &str, + data: &serde_json::Value, +) -> Result<(), WriteFailure> { + let partition_key = Utc::now().format("%Y-%m-%d").to_string(); + let record_hash = canonical_outbox_hash(table, &partition_key, data)?; + let data_json = checked_json(data, "Lakehouse outbox record")?; + let outbox_id = Uuid::new_v4().to_string(); + transaction + .execute( + "INSERT INTO lakehouse_ingest_records + (id, table_name, partition_key, data, schema_version, source_system, record_hash, delivery_status, next_attempt_at) + VALUES + ($1::text::uuid, $2, $3, $4::text::jsonb, $5, $6, $7, 'pending', NOW()) + ON CONFLICT (record_hash) DO NOTHING", + &[ + &outbox_id, + &table, + &partition_key, + &data_json, + &OUTBOX_SCHEMA_VERSION, + &OUTBOX_SOURCE_SYSTEM, + &record_hash, + ], + ) + .await + .map_err(|error| WriteFailure::Unavailable(format!("durable Lakehouse enqueue failed: {error}")))?; + Ok(()) +} - // Write to PostgreSQL feature store - match get_db_client(&state.db_url).await { - Ok(client) => { - let features_json = serde_json::to_string(&req.features).unwrap_or_default(); - let result = client.execute( - "INSERT INTO ml_feature_store (id, feature_group, entity_id, entity_type, features, recorded_at) - VALUES ($1::uuid, $2, $3, $4, $5::jsonb, $6::timestamptz) - ON CONFLICT (feature_group, entity_id) DO UPDATE - SET features = EXCLUDED.features, recorded_at = EXCLUDED.recorded_at", - &[&record_id, &req.feature_group, &req.entity_id, &req.entity_type, &features_json, &ts], - ).await; - - match result { - Ok(_) => { - state.feature_writes.fetch_add(1, Ordering::Relaxed); - log::debug!( - "[Lakehouse] Feature write: {} / {}", - req.feature_group, - req.entity_id - ); - } - Err(e) => { - state.writes_failed.fetch_add(1, Ordering::Relaxed); - log::error!("[Lakehouse] Feature write failed: {}", e); - } - } - } - Err(e) => { - state.writes_failed.fetch_add(1, Ordering::Relaxed); - log::error!("[Lakehouse] DB connect failed: {}", e); +fn unavailable(error: impl Into) -> WriteFailure { + WriteFailure::Unavailable(error.into()) +} + +fn error_response(status: StatusCode, message: impl Into) -> Response { + ( + status, + Json(serde_json::json!({"success": false, "error": message.into()})), + ) + .into_response() +} + +fn failed_write_response(state: &AppState, failure: WriteFailure) -> Response { + state.writes_failed.fetch_add(1, Ordering::Relaxed); + match failure { + WriteFailure::BadRequest(message) => error_response(StatusCode::BAD_REQUEST, message), + WriteFailure::Unavailable(error) => { + log::error!("[Lakehouse] Durable writer operation failed: {error}"); + error_response( + StatusCode::SERVICE_UNAVAILABLE, + "durable Lakehouse write is unavailable", + ) } } +} + +fn successful_write_response(response: WriteResponse) -> Response { + (StatusCode::CREATED, Json(response)).into_response() +} + +// ── Durable write operations ─────────────────────────────────────────────────── + +async fn persist_feature( + state: &AppState, + req: FeatureWriteRequest, +) -> Result { + if !valid_label(&req.feature_group, MAX_LABEL_LENGTH) + || !valid_label(&req.entity_id, MAX_LABEL_LENGTH) + || !valid_label(&req.entity_type, MAX_LABEL_LENGTH) + { + return Err(WriteFailure::BadRequest( + "feature_group, entity_id, and entity_type must be bounded non-empty labels" + .to_string(), + )); + } + if req.features.is_empty() { + return Err(WriteFailure::BadRequest( + "features must contain at least one value".to_string(), + )); + } - // Forward to Lakehouse Analytics Engine for Parquet (offline store) - let fwd_record = serde_json::json!({ + let timestamp = normalized_timestamp(req.timestamp)?; + let features = serde_json::to_value(&req.features) + .map_err(|error| WriteFailure::BadRequest(format!("features are invalid: {error}")))?; + let features_json = checked_json(&features, "features")?; + let record_id = Uuid::new_v4().to_string(); + let durable_event = serde_json::json!({ + "record_id": &record_id, "feature_group": &req.feature_group, "entity_id": &req.entity_id, "entity_type": &req.entity_type, - "features": &req.features, - "recorded_at": &ts, + "features": &features, + "recorded_at": ×tamp, }); - forward_to_parquet(&state, "ml_features", &fwd_record).await; - Json(WriteResponse { + let mut client = get_db_client(&state.db_url).await.map_err(unavailable)?; + let transaction = client.transaction().await.map_err(|error| { + unavailable(format!( + "cannot begin Lakehouse feature transaction: {error}" + )) + })?; + transaction + .execute( + "INSERT INTO ml_feature_store (id, feature_group, entity_id, entity_type, features, recorded_at) + VALUES ($1::text::uuid, $2, $3, $4, $5::text::jsonb, $6::text::timestamptz) + ON CONFLICT (feature_group, entity_id) DO UPDATE + SET features = EXCLUDED.features, entity_type = EXCLUDED.entity_type, + recorded_at = EXCLUDED.recorded_at", + &[ + &record_id, + &req.feature_group, + &req.entity_id, + &req.entity_type, + &features_json, + ×tamp, + ], + ) + .await + .map_err(|error| unavailable(format!("durable feature write failed: {error}")))?; + enqueue_lakehouse_delivery(&transaction, "ml_features", &durable_event).await?; + transaction.commit().await.map_err(|error| { + unavailable(format!( + "Lakehouse feature transaction commit failed: {error}" + )) + })?; + + state.writes_total.fetch_add(1, Ordering::Relaxed); + state.feature_writes.fetch_add(1, Ordering::Relaxed); + state.lakehouse_enqueues.fetch_add(1, Ordering::Relaxed); + Ok(WriteResponse { status: "written".to_string(), record_id, - timestamp: ts, + timestamp, feature_group: Some(req.feature_group), }) } -async fn log_prediction( - State(state): State, - Json(req): Json, -) -> Json { - let record_id = Uuid::new_v4().to_string(); - let ts = Utc::now().to_rfc3339(); - state.writes_total.fetch_add(1, Ordering::Relaxed); - - match get_db_client(&state.db_url).await { - Ok(client) => { - let input_json = serde_json::to_string(&req.input_features).unwrap_or_default(); - let pred_json = serde_json::to_string(&req.prediction).unwrap_or_default(); - let result = client.execute( - "INSERT INTO ml_prediction_log (id, model_name, model_version, entity_id, input_features, prediction, confidence, latency_ms, predicted_at) - VALUES ($1::uuid, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8::bigint, NOW())", - &[&record_id, &req.model_name, &req.model_version, &req.entity_id, - &input_json, &pred_json, &req.confidence, &(req.latency_ms as i64)], - ).await; - - match result { - Ok(_) => { - state.prediction_writes.fetch_add(1, Ordering::Relaxed); - } - Err(e) => { - state.writes_failed.fetch_add(1, Ordering::Relaxed); - log::error!("[Lakehouse] Prediction log failed: {}", e); - } - } - } - Err(e) => { - state.writes_failed.fetch_add(1, Ordering::Relaxed); - log::error!("[Lakehouse] DB connect failed: {}", e); - } +async fn persist_prediction( + state: &AppState, + req: PredictionLogRequest, +) -> Result { + if !valid_label(&req.model_name, MAX_LABEL_LENGTH) + || !valid_label(&req.model_version, MAX_LABEL_LENGTH) + || !valid_label(&req.entity_id, MAX_LABEL_LENGTH) + { + return Err(WriteFailure::BadRequest( + "model_name, model_version, and entity_id must be bounded non-empty labels".to_string(), + )); } - - // Forward prediction to Lakehouse Analytics Engine for Parquet (offline store) - let fwd_record = serde_json::json!({ + if req.input_features.is_empty() { + return Err(WriteFailure::BadRequest( + "input_features must contain at least one value".to_string(), + )); + } + if !req.confidence.is_finite() || !(0.0..=1.0).contains(&req.confidence) { + return Err(WriteFailure::BadRequest( + "confidence must be a finite value from 0 to 1".to_string(), + )); + } + let latency_ms = i64::try_from(req.latency_ms) + .map_err(|_| WriteFailure::BadRequest("latency_ms exceeds BIGINT".to_string()))?; + let input_features = serde_json::to_value(&req.input_features).map_err(|error| { + WriteFailure::BadRequest(format!("input_features are invalid: {error}")) + })?; + let input_json = checked_json(&input_features, "input_features")?; + let prediction_json = checked_json(&req.prediction, "prediction")?; + let timestamp = Utc::now().to_rfc3339(); + let record_id = Uuid::new_v4().to_string(); + let durable_event = serde_json::json!({ + "record_id": &record_id, "model_name": &req.model_name, "model_version": &req.model_version, "entity_id": &req.entity_id, + "input_features": &input_features, + "prediction": &req.prediction, "confidence": req.confidence, "latency_ms": req.latency_ms, - "predicted_at": &ts, + "predicted_at": ×tamp, }); - forward_to_parquet(&state, "ml_predictions", &fwd_record).await; - Json(WriteResponse { + let mut client = get_db_client(&state.db_url).await.map_err(unavailable)?; + let transaction = client.transaction().await.map_err(|error| { + unavailable(format!( + "cannot begin Lakehouse prediction transaction: {error}" + )) + })?; + transaction + .execute( + "INSERT INTO ml_prediction_log + (id, model_name, model_version, entity_id, input_features, prediction, confidence, latency_ms, predicted_at) + VALUES ($1::text::uuid, $2, $3, $4, $5::text::jsonb, $6::text::jsonb, $7, $8::bigint, $9::text::timestamptz)", + &[ + &record_id, + &req.model_name, + &req.model_version, + &req.entity_id, + &input_json, + &prediction_json, + &req.confidence, + &latency_ms, + ×tamp, + ], + ) + .await + .map_err(|error| unavailable(format!("durable prediction write failed: {error}")))?; + enqueue_lakehouse_delivery(&transaction, "ml_predictions", &durable_event).await?; + transaction.commit().await.map_err(|error| { + unavailable(format!( + "Lakehouse prediction transaction commit failed: {error}" + )) + })?; + + state.writes_total.fetch_add(1, Ordering::Relaxed); + state.prediction_writes.fetch_add(1, Ordering::Relaxed); + state.lakehouse_enqueues.fetch_add(1, Ordering::Relaxed); + Ok(WriteResponse { status: "logged".to_string(), record_id, - timestamp: ts, + timestamp, feature_group: Some(format!("prediction:{}", req.model_name)), }) } -async fn write_lineage( - State(state): State, - Json(req): Json, -) -> Json { +async fn persist_lineage( + state: &AppState, + req: LineageRequest, +) -> Result { + if !valid_label(&req.source_table, MAX_LABEL_LENGTH) + || !valid_label(&req.target_table, MAX_LABEL_LENGTH) + || !valid_label(&req.transformation, MAX_LABEL_LENGTH) + || !valid_label(&req.pipeline_run_id, MAX_LABEL_LENGTH) + { + return Err(WriteFailure::BadRequest( + "lineage fields must be bounded non-empty labels".to_string(), + )); + } + let record_count = i64::try_from(req.record_count) + .map_err(|_| WriteFailure::BadRequest("record_count exceeds BIGINT".to_string()))?; + let timestamp = Utc::now().to_rfc3339(); let record_id = Uuid::new_v4().to_string(); - let ts = Utc::now().to_rfc3339(); - state.writes_total.fetch_add(1, Ordering::Relaxed); + let durable_event = serde_json::json!({ + "record_id": &record_id, + "source_table": &req.source_table, + "target_table": &req.target_table, + "transformation": &req.transformation, + "record_count": req.record_count, + "pipeline_run_id": &req.pipeline_run_id, + "created_at": ×tamp, + }); - match get_db_client(&state.db_url).await { - Ok(client) => { - let result = client.execute( - "INSERT INTO ml_lineage (id, source_table, target_table, transformation, record_count, pipeline_run_id, created_at) - VALUES ($1::uuid, $2, $3, $4, $5::bigint, $6, NOW())", - &[&record_id, &req.source_table, &req.target_table, - &req.transformation, &(req.record_count as i64), &req.pipeline_run_id], - ).await; - - match result { - Ok(_) => { - state.lineage_writes.fetch_add(1, Ordering::Relaxed); - } - Err(e) => { - state.writes_failed.fetch_add(1, Ordering::Relaxed); - log::error!("[Lakehouse] Lineage write failed: {}", e); - } - } - } - Err(e) => { - state.writes_failed.fetch_add(1, Ordering::Relaxed); - log::error!("[Lakehouse] DB connect failed: {}", e); - } - } + let mut client = get_db_client(&state.db_url).await.map_err(unavailable)?; + let transaction = client.transaction().await.map_err(|error| { + unavailable(format!( + "cannot begin Lakehouse lineage transaction: {error}" + )) + })?; + transaction + .execute( + "INSERT INTO ml_lineage + (id, source_table, target_table, transformation, record_count, pipeline_run_id, created_at) + VALUES ($1::text::uuid, $2, $3, $4, $5::bigint, $6, $7::text::timestamptz)", + &[ + &record_id, + &req.source_table, + &req.target_table, + &req.transformation, + &record_count, + &req.pipeline_run_id, + ×tamp, + ], + ) + .await + .map_err(|error| unavailable(format!("durable lineage write failed: {error}")))?; + enqueue_lakehouse_delivery(&transaction, "ml_lineage", &durable_event).await?; + transaction.commit().await.map_err(|error| { + unavailable(format!( + "Lakehouse lineage transaction commit failed: {error}" + )) + })?; - Json(WriteResponse { + state.writes_total.fetch_add(1, Ordering::Relaxed); + state.lineage_writes.fetch_add(1, Ordering::Relaxed); + state.lakehouse_enqueues.fetch_add(1, Ordering::Relaxed); + Ok(WriteResponse { status: "lineage_recorded".to_string(), record_id, - timestamp: ts, + timestamp, feature_group: None, }) } +// ── HTTP handlers ────────────────────────────────────────────────────────────── + +async fn health(State(state): State) -> Response { + let status = match get_db_client(&state.db_url).await { + Ok(client) => match ensure_migration_tables(&client).await { + Ok(()) => (StatusCode::OK, "healthy".to_string()), + Err(error) => { + log::error!("[Lakehouse] health check migration validation failed: {error}"); + (StatusCode::SERVICE_UNAVAILABLE, "unavailable".to_string()) + } + }, + Err(error) => { + log::error!("[Lakehouse] health check database validation failed: {error}"); + (StatusCode::SERVICE_UNAVAILABLE, "unavailable".to_string()) + } + }; + ( + status.0, + Json(HealthResponse { + status: status.1, + worker: "lakehouse_writer".to_string(), + writes_total: state.writes_total.load(Ordering::Relaxed), + writes_failed: state.writes_failed.load(Ordering::Relaxed), + feature_writes: state.feature_writes.load(Ordering::Relaxed), + prediction_writes: state.prediction_writes.load(Ordering::Relaxed), + lineage_writes: state.lineage_writes.load(Ordering::Relaxed), + lakehouse_enqueues: state.lakehouse_enqueues.load(Ordering::Relaxed), + uptime_seconds: state.start_time.elapsed().as_secs_f64(), + }), + ) + .into_response() +} + +async fn write_features( + State(state): State, + Json(req): Json, +) -> Response { + match persist_feature(&state, req).await { + Ok(response) => successful_write_response(response), + Err(failure) => failed_write_response(&state, failure), + } +} + +async fn log_prediction( + State(state): State, + Json(req): Json, +) -> Response { + match persist_prediction(&state, req).await { + Ok(response) => successful_write_response(response), + Err(failure) => failed_write_response(&state, failure), + } +} + +async fn write_lineage(State(state): State, Json(req): Json) -> Response { + match persist_lineage(&state, req).await { + Ok(response) => successful_write_response(response), + Err(failure) => failed_write_response(&state, failure), + } +} + async fn get_features( State(state): State, Json(req): Json, -) -> Json { +) -> Response { let feature_group = req .get("feature_group") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) .unwrap_or(""); - let entity_id = req.get("entity_id").and_then(|v| v.as_str()).unwrap_or(""); - - match get_db_client(&state.db_url).await { - Ok(client) => { - let rows = client - .query( - "SELECT id::text, feature_group, entity_id, entity_type, features, recorded_at - FROM ml_feature_store - WHERE feature_group = $1 AND entity_id = $2 - ORDER BY recorded_at DESC LIMIT 1", - &[&feature_group, &entity_id], - ) - .await; - - match rows { - Ok(rows) if !rows.is_empty() => { - let row = &rows[0]; - let features: serde_json::Value = row.get::<_, serde_json::Value>(4); - Json(serde_json::json!({ - "found": true, - "feature_group": feature_group, - "entity_id": entity_id, - "features": features, - "recorded_at": row.get::<_, String>(5) - })) - } - _ => Json(serde_json::json!({ - "found": false, - "feature_group": feature_group, - "entity_id": entity_id - })), - } + let entity_id = req + .get("entity_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if !valid_label(feature_group, MAX_LABEL_LENGTH) || !valid_label(entity_id, MAX_LABEL_LENGTH) { + return error_response( + StatusCode::BAD_REQUEST, + "feature_group and entity_id must be bounded non-empty labels", + ); + } + + let client = match get_db_client(&state.db_url).await { + Ok(client) => client, + Err(error) => { + log::error!("[Lakehouse] feature read database connection failed: {error}"); + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "durable Lakehouse feature store is unavailable", + ); } - Err(e) => Json(serde_json::json!({ - "error": e.to_string() - })), + }; + let rows = match client + .query( + "SELECT id::text, feature_group, entity_id, entity_type, features, recorded_at::text + FROM ml_feature_store + WHERE feature_group = $1 AND entity_id = $2 + ORDER BY recorded_at DESC + LIMIT 1", + &[&feature_group, &entity_id], + ) + .await + { + Ok(rows) => rows, + Err(error) => { + log::error!("[Lakehouse] feature read failed: {error}"); + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "durable Lakehouse feature store is unavailable", + ); + } + }; + + match rows.first() { + Some(row) => ( + StatusCode::OK, + Json(serde_json::json!({ + "found": true, + "record_id": row.get::<_, String>(0), + "feature_group": row.get::<_, String>(1), + "entity_id": row.get::<_, String>(2), + "entity_type": row.get::<_, String>(3), + "features": row.get::<_, serde_json::Value>(4), + "recorded_at": row.get::<_, String>(5), + })), + ) + .into_response(), + None => ( + StatusCode::OK, + Json(serde_json::json!({ + "found": false, + "feature_group": feature_group, + "entity_id": entity_id, + })), + ) + .into_response(), } } // ── Main ─────────────────────────────────────────────────────────────────────── + #[tokio::main] async fn main() { env_logger::init(); - let db_url = get_env( - "DATABASE_URL", - "postgresql://ndsep_user:ndsep_secure_2026@localhost:5432/ndsep_db", - ); - let port = get_env("LAKEHOUSE_PORT", "8215"); - - log::info!( - "[Lakehouse] Starting NDSEP Lakehouse Feature Store Writer on port {}", - port - ); - - // Ensure tables exist - if let Ok(client) = get_db_client(&db_url).await { - let _ = client.batch_execute(" - CREATE TABLE IF NOT EXISTS ml_feature_store ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - feature_group TEXT NOT NULL, - entity_id TEXT NOT NULL, - entity_type TEXT NOT NULL DEFAULT 'organization', - features JSONB NOT NULL DEFAULT '{}', - recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (feature_group, entity_id) - ); - CREATE TABLE IF NOT EXISTS ml_prediction_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - model_name TEXT NOT NULL, - model_version TEXT NOT NULL, - entity_id TEXT NOT NULL, - input_features JSONB NOT NULL DEFAULT '{}', - prediction JSONB NOT NULL, - confidence DOUBLE PRECISION NOT NULL DEFAULT 0, - latency_ms BIGINT NOT NULL DEFAULT 0, - predicted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE TABLE IF NOT EXISTS ml_lineage ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - source_table TEXT NOT NULL, - target_table TEXT NOT NULL, - transformation TEXT NOT NULL, - record_count BIGINT NOT NULL DEFAULT 0, - pipeline_run_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE INDEX IF NOT EXISTS idx_feature_store_group_entity ON ml_feature_store(feature_group, entity_id); - CREATE INDEX IF NOT EXISTS idx_prediction_log_model ON ml_prediction_log(model_name, predicted_at DESC); - CREATE INDEX IF NOT EXISTS idx_lineage_pipeline ON ml_lineage(pipeline_run_id, created_at DESC); - ").await; - log::info!("[Lakehouse] Tables ensured"); - } + let db_url = required_postgres_url().unwrap_or_else(|error| panic!("{error}")); + let port = configured_port(); - let lakehouse_url = get_env("LAKEHOUSE_ANALYTICS_URL", "http://localhost:8140"); - let state = AppState::new(db_url, lakehouse_url); + let client = get_db_client(&db_url) + .await + .unwrap_or_else(|error| panic!("Lakehouse writer PostgreSQL connection failed: {error}")); + ensure_migration_tables(&client) + .await + .unwrap_or_else(|error| { + panic!("Lakehouse writer migration-owned tables are not ready: {error}") + }); + log::info!("[Lakehouse] PostgreSQL migration-owned tables are reachable"); + let state = AppState::new(db_url); let app = Router::new() .route("/health", get(health)) .route("/features/write", post(write_features)) @@ -449,8 +738,132 @@ async fn main() { .route("/lineage/write", post(write_lineage)) .with_state(state); - let addr = format!("0.0.0.0:{}", port); - log::info!("[Lakehouse] Listening on {}", addr); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + let addr = format!("0.0.0.0:{port}"); + log::info!("[Lakehouse] Lakehouse Feature Store Writer listening on {addr}"); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|error| panic!("Lakehouse writer cannot bind {addr}: {error}")); + axum::serve(listener, app) + .await + .unwrap_or_else(|error| panic!("Lakehouse writer server failed: {error}")); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disposable_database_url() -> Option { + let url = env::var("LAKEHOUSE_WRITER_TEST_DATABASE_URL") + .or_else(|_| env::var("LAKEHOUSE_INGEST_TEST_DATABASE_URL")) + .ok()?; + if url + .rsplit_once('@') + .map(|(_, authority)| { + authority.starts_with("127.0.0.1:") || authority.starts_with("localhost:") + }) + .unwrap_or(false) + { + Some(url) + } else { + panic!("Lakehouse writer test database must target disposable localhost PostgreSQL") + } + } + + #[test] + fn rejects_invalid_external_labels() { + assert!(valid_label("risk-features/v1", MAX_LABEL_LENGTH)); + assert!(!valid_label("", MAX_LABEL_LENGTH)); + assert!(!valid_label("has space", MAX_LABEL_LENGTH)); + assert!(!valid_label("../traversal", MAX_LABEL_LENGTH)); + } + + #[tokio::test] + async fn database_unavailability_is_an_error_not_a_success_response() { + let state = AppState::new("not-a-postgres-url".to_string()); + let result = persist_feature( + &state, + FeatureWriteRequest { + feature_group: "test-group".to_string(), + entity_id: "entity-1".to_string(), + entity_type: "organization".to_string(), + features: HashMap::from([("score".to_string(), serde_json::json!(0.42))]), + timestamp: None, + }, + ) + .await; + assert!(matches!(result, Err(WriteFailure::Unavailable(_)))); + assert_eq!(state.writes_total.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn feature_write_commits_primary_row_and_durable_lakehouse_ledger_together() { + let Some(url) = disposable_database_url() else { + return; + }; + let (client, connection) = tokio_postgres::connect(&url, NoTls) + .await + .expect("connect disposable Lakehouse writer database"); + tokio::spawn(async move { + let _ = connection.await; + }); + client + .batch_execute(include_str!( + "../../../../drizzle/0039_lakehouse_durable_postgres_storage.sql" + )) + .await + .expect("apply active Lakehouse migration"); + + let group = format!("writer-test-{}", Uuid::new_v4()); + let state = AppState::new(url); + let response = persist_feature( + &state, + FeatureWriteRequest { + feature_group: group.clone(), + entity_id: "organization-1".to_string(), + entity_type: "organization".to_string(), + features: HashMap::from([("risk_score".to_string(), serde_json::json!(0.42))]), + timestamp: Some("2026-09-01T12:00:00Z".to_string()), + }, + ) + .await + .expect("persist primary ML feature and durable Lakehouse event"); + + let primary_count: i64 = client + .query_one( + "SELECT COUNT(*) FROM ml_feature_store WHERE feature_group = $1", + &[&group], + ) + .await + .expect("read primary row") + .get(0); + let ledger_count: i64 = client + .query_one( + "SELECT COUNT(*) FROM lakehouse_ingest_records + WHERE table_name = 'ml_features' AND source_system = $1 + AND data->>'record_id' = $2", + &[&OUTBOX_SOURCE_SYSTEM, &response.record_id], + ) + .await + .expect("read durable Lakehouse ledger row") + .get(0); + assert_eq!(primary_count, 1); + assert_eq!(ledger_count, 1); + assert_eq!(state.writes_total.load(Ordering::Relaxed), 1); + assert_eq!(state.lakehouse_enqueues.load(Ordering::Relaxed), 1); + + client + .execute( + "DELETE FROM ml_feature_store WHERE feature_group = $1", + &[&group], + ) + .await + .expect("clean primary test row"); + client + .execute( + "DELETE FROM lakehouse_ingest_records WHERE data->>'record_id' = $1", + &[&response.record_id], + ) + .await + .expect("clean durable ledger test row"); + } } diff --git a/workers/rust/opensearch_indexer/src/main.rs b/workers/rust/opensearch_indexer/src/main.rs index cff5826b..ae68ceb1 100644 --- a/workers/rust/opensearch_indexer/src/main.rs +++ b/workers/rust/opensearch_indexer/src/main.rs @@ -125,7 +125,8 @@ impl AppState { } } -async fn ensure_indices(state: &AppState) { +async fn ensure_indices(state: &AppState) -> Result<(), String> { + let mut failures = Vec::new(); for (index_name, mapping) in NDSEP_INDICES { let url = format!("{}/{}", state.opensearch_url, index_name); let mut req = state @@ -138,15 +139,20 @@ async fn ensure_indices(state: &AppState) { } match req.send().await { Ok(resp) => { - if resp.status().is_success() || resp.status().as_u16() == 400 { - // 400 = index already exists, that's fine + if !(resp.status().is_success() || resp.status().as_u16() == 400) { + failures.push(format!("{} returned HTTP {}", index_name, resp.status())); } } Err(e) => { - tracing::warn!("OpenSearch not available for index {}: {}", index_name, e); + failures.push(format!("{} unavailable: {}", index_name, e)); } } } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } } async fn index_document( @@ -469,6 +475,20 @@ async fn main() { let opensearch_url = get_env("OPENSEARCH_URL", "http://localhost:9200"); let opensearch_user = get_env("OPENSEARCH_USER", ""); let opensearch_pass = get_env("OPENSEARCH_PASSWORD", ""); + let production = env::var("APP_ENV") + .map(|value| value.eq_ignore_ascii_case("production")) + .unwrap_or(false) + || env::var("NODE_ENV") + .map(|value| value.eq_ignore_ascii_case("production")) + .unwrap_or(false); + if production && !opensearch_url.starts_with("https://") { + tracing::error!("production OpenSearch requires an https:// OPENSEARCH_URL"); + std::process::exit(1); + } + if production && (opensearch_user.is_empty() || opensearch_pass.is_empty()) { + tracing::error!("production OpenSearch requires OPENSEARCH_USER and OPENSEARCH_PASSWORD"); + std::process::exit(1); + } if !opensearch_user.is_empty() && opensearch_pass.is_empty() { tracing::error!("OPENSEARCH_PASSWORD must be configured when OPENSEARCH_USER is set"); std::process::exit(1); @@ -479,16 +499,32 @@ async fn main() { format!("{}:{}", opensearch_user, opensearch_pass) }; + let client = Client::builder() + .https_only(production) + .build() + .unwrap_or_else(|error| { + tracing::error!("create OpenSearch HTTP client: {}", error); + std::process::exit(1); + }); let state = AppState { - client: Client::new(), + client, opensearch_url: opensearch_url.clone(), opensearch_auth, start_time: Instant::now(), index_count: Arc::new(AtomicU64::new(0)), }; - // Initialize indices on startup - ensure_indices(&state).await; + // A production indexer is not ready until the authenticated upstream is reachable. + if let Err(error) = ensure_indices(&state).await { + if production { + tracing::error!("OpenSearch index initialization failed: {}", error); + std::process::exit(1); + } + tracing::warn!( + "OpenSearch index initialization deferred outside production: {}", + error + ); + } let app = Router::new() .route("/index", post(index_document))