Files
support_backend/Jenkinsfile
T
saqib mirandClaude Sonnet 5 8d5731340d feat: implement SaaS product integration trust boundary (US1 MVP)
Implements tasks T001-T020 from specs/002-saas-integration/tasks.md
(Setup, Foundational, and User Story 1 - the P1 MVP: every inbound
request is authenticated and trusted before anything happens).
User Story 2 (admin onboarding/rotation/revocation) and User Story 3
(rate limiting) are not yet implemented (T021-T032 remain).

Schema (prisma/schema.prisma + initial migration):
- Replace the placeholder Product model (leftover starter-template
  scaffolding: code/description/ProductStatus enum) with the real
  docs/06-database-schema.md shape (externalProductId,
  supportEnabled, status).
- Add ProductIntegration (credential ref, rotation/revocation state,
  allowed scope, per-integration/per-user rate limits) and
  CustomerReference models.
- Align AuditLog to docs/06's shape (actor/actorType/entityType/
  entityId/reason/metadata) -- the placeholder shape had no fields
  to satisfy this feature's audit requirements.

Auth:
- HMAC-signed short-lived tokens (issue/verify) with jti-based replay
  defense via Redis and a bounded clock-skew tolerance.
- Credential secrets are AES-256-GCM encrypted at rest (new required
  INTEGRATION_CREDENTIAL_ENCRYPTION_KEY env var) since no secret
  manager exists in this stack yet -- see research.md "Credential
  storage".
- New product-integration-auth.plugin.ts Fastify plugin runs the
  validation order in contracts/inbound-request-contract.md and
  populates request.reqContext only on full success; every attempt
  (success or failure) is audit-logged without ever persisting the
  raw token/credential. Unregistered product and invalid credential
  return an identical response (FR-010).
- New POST /v1/support/requests endpoint exercises the boundary
  end-to-end (ticket creation itself is a future feature).

Also:
- Fix docker-compose.test.yml's container_name collisions --
  discovered while testing this change concurrently is now covered
  by an app-level regression test (separate commit).
- Fix test:unit to scope to tests/unit only (it was running the
  entire tests/** glob including integration tests) -- this feature's
  new integration test makes real Prisma/Redis calls, unlike the
  prior instantiation-only checks, so the existing glob-scoping gap
  became actually harmful.
- Update Jenkinsfile with the new required credential.

Verified: full quality gate (typecheck/lint/format/architecture/
unit tests) passes; all of User Story 1's quickstart scenarios
manually verified end-to-end against a live server + Postgres +
Redis; the new integration test suite verified against a live
database (not run as part of `npm test`, matches existing
test:integration convention).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:40:43 +05:30

224 lines
9.0 KiB
Groovy

// CI/CD pipeline for supporthub-api.
//
// Stage order and guarantees are defined by:
// - .specify/memory/constitution.md -> "Testing, Observability & CI/CD Gates"
// - specs/001-ci-pipeline/contracts/pipeline-stage-contract.md
//
// Prerequisites (configured on the Jenkins side, never in this repo):
// - Agents with Docker and Node.js 20+ available (label: 'docker && node20')
// - Credentials (Secret text unless noted) per target environment <env> in [test, prod]:
// <env>-postgres-password, <env>-redis-password, <env>-jwt-secret
// <env>-aws-access-key-id, <env>-aws-secret-access-key
// <env>-integration-credential-encryption-key (64 hex chars / 32 bytes — see
// specs/002-saas-integration/research.md "Credential storage")
// Plus one Username/Password credential: docker-registry-credentials
// - A DOCKER_REGISTRY value (e.g. via a "CI_DOCKER_REGISTRY" global Jenkins env var,
// or override the default below) pointing at the org's actual image registry.
//
// This file intentionally contains no secret values, only credential IDs — see
// specs/001-ci-pipeline/research.md "Environment/secrets handling in the pipeline".
pipeline {
agent { label 'docker && node20' }
options {
timestamps()
buildDiscarder(logRotator(numToKeepStr: '30'))
// Each build gets its own workspace by default; Compose project names below are
// additionally scoped by BUILD_NUMBER so concurrent runs never share containers,
// networks, or volumes (FR-009 / SC-005).
ansiColor('xterm')
}
environment {
DOCKER_REGISTRY = "${env.CI_DOCKER_REGISTRY ?: 'registry.example.com/supporthub'}"
IMAGE_NAME = 'supporthub-api'
COMPOSE_PROJECT = "supporthub-ci-${env.BUILD_NUMBER}"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('Environment validation') {
steps {
script {
resolveDeployTarget()
writeTargetEnvFile(env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV)
}
// Reuses the existing Zod schema in src/config/env.ts as-is: it throws a
// specific, descriptive error on safeParse failure, so this fails fast with
// no new validation logic (FR-002).
sh "npx tsx --env-file=.env.${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV} -e \"import('./src/config/env.ts').then(() => console.log('Environment OK'))\""
}
}
stage('Generate Prisma client') {
steps {
sh 'npm run prisma:generate'
}
}
stage('Typecheck') {
steps {
sh 'npm run typecheck'
}
}
stage('Lint') {
steps {
sh 'npm run lint'
// Enforces Constitution Principle III (module boundaries) server-side —
// mirrors .husky/pre-commit so a bypassed/missing local hook can't merge
// a boundary violation.
sh 'npx tsx scripts/check-architecture.ts'
}
}
stage('Format check') {
steps {
sh 'npm run format:check'
}
}
stage('Unit test') {
steps {
sh 'npm run test:unit'
}
}
stage('Integration test') {
steps {
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml up -d --wait postgres redis"
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm --build app npm run test:integration"
}
}
stage('E2E test') {
steps {
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm app npm run test:e2e"
}
}
stage('Build') {
steps {
script {
def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV
sh "npm run build:${buildTarget}"
}
}
}
stage('Docker build') {
steps {
script {
def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV
env.IMAGE_TAG = "${env.DOCKER_REGISTRY}/${env.IMAGE_NAME}:${buildTarget}-${env.BUILD_NUMBER}"
}
sh "docker build --build-arg BUILD_COMMAND=\"npm run build:${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV}\" -t ${env.IMAGE_TAG} ."
}
}
stage('Publish') {
when { expression { env.TARGET_ENV != 'none' } }
steps {
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''
echo "$REGISTRY_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$REGISTRY_USER" --password-stdin
docker push "$IMAGE_TAG"
docker logout "$DOCKER_REGISTRY"
'''
}
}
}
stage('Deploy') {
when { expression { env.TARGET_ENV != 'none' } }
steps {
script {
writeTargetEnvFile(env.TARGET_ENV)
}
sh "docker compose --env-file .env.${env.TARGET_ENV} -f docker-compose.${env.TARGET_ENV == 'prod' ? 'prod' : 'test'}.yml up -d"
}
}
}
post {
always {
// Tear down the ephemeral integration/E2E stack regardless of outcome, and never
// leave a generated .env.* file behind in the workspace. --env-file must be passed
// to `down` too, or Compose can fail to resolve service config and leave containers
// running (confirmed while validating this pipeline locally).
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml down -v --remove-orphans || true"
sh 'rm -f .env.test .env.prod .env.development'
cleanWs()
}
failure {
echo "Pipeline failed at stage: ${currentBuild.result}. See the failing stage's log above for the exact command and output — no local reproduction should be necessary (FR-004)."
}
}
}
// Resolves env.TARGET_ENV from the branch being built:
// main -> prod
// develop/test -> test
// anything else -> none (validate-only; Publish/Deploy stages are skipped, not failed)
void resolveDeployTarget() {
def branch = env.BRANCH_NAME ?: ''
if (branch == 'main') {
env.TARGET_ENV = 'prod'
} else if (branch == 'develop' || branch == 'test') {
env.TARGET_ENV = 'test'
} else {
env.TARGET_ENV = 'none'
}
echo "Resolved deploy target for branch '${branch}': ${env.TARGET_ENV}"
}
// Writes .env.<target> into the workspace from Jenkins credentials — never read from a
// file committed to the repository (constitution governance: "secrets never committed").
void writeTargetEnvFile(String target) {
withCredentials([
string(credentialsId: "${target}-postgres-password", variable: 'POSTGRES_PASSWORD'),
string(credentialsId: "${target}-redis-password", variable: 'REDIS_PASSWORD'),
string(credentialsId: "${target}-jwt-secret", variable: 'JWT_SECRET'),
string(credentialsId: "${target}-aws-access-key-id", variable: 'AWS_ACCESS_KEY_ID'),
string(credentialsId: "${target}-aws-secret-access-key", variable: 'AWS_SECRET_ACCESS_KEY'),
string(credentialsId: "${target}-integration-credential-encryption-key", variable: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'),
]) {
def port = target == 'prod' ? '4503' : (target == 'test' ? '4502' : '4501')
def dbName = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_dev')
def dbUser = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_user')
writeFile file: ".env.${target}", text: """
NODE_ENV=${target == 'prod' ? 'production' : target}
PORT=${port}
BUILD_COMMAND=npm run build:${target}
POSTGRES_HOST=postgres
POSTGRES_DB=${dbName}
POSTGRES_USER=${dbUser}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
DATABASE_URL=postgresql://${dbUser}:${POSTGRES_PASSWORD}@postgres:5432/${dbName}
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=${REDIS_PASSWORD}
JWT_SECRET=${JWT_SECRET}
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=${INTEGRATION_CREDENTIAL_ENCRYPTION_KEY}
CORS_ORIGINS=${target == 'prod' ? 'https://app.supporthub.com,https://admin.supporthub.com' : 'http://localhost:3000'}
""".stripIndent().trim()
}
}